From 6d65393d0e375943e0ecf189bbd447f7d9a9eeaa Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Mon, 6 Jul 2026 11:46:25 +0800 Subject: [PATCH 01/12] Update sandbox gap analytic Signed-off-by: Eli Ma --- docs/development/tracing/sandbox.md | 99 ++++++++++++++++++++++++++++- web/package.json | 2 +- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/docs/development/tracing/sandbox.md b/docs/development/tracing/sandbox.md index db2a875ba..25ddc065c 100644 --- a/docs/development/tracing/sandbox.md +++ b/docs/development/tracing/sandbox.md @@ -4,7 +4,7 @@ > 兼容级别:`intentionally-different`(Libra AI 安全运行扩展,非 Git 命令) > 适用范围:`libra code`(TUI / headless / web-only 会话)执行 AI agent shell 命令时的进程隔离后端 > 关联文档:[`docs/development/commands/sandbox.md`](commands/sandbox.md)(`libra sandbox status` 命令设计)、[`COMPATIBILITY.md`](../../COMPATIBILITY.md) -> 调研基线:apple/container `1.0.0`、apple/containerization(pre-1.0),均为 Apple Silicon + macOS 26 (Tahoe) 目标 +> 调研基线:apple/container `1.0.0`、apple/containerization(pre-1.0),均为 Apple Silicon + macOS 26 (Tahoe) 目标;ricccrd/dd `9b950a2`(2026-07-04,`https://github.com/ricccrd/dd`) --- @@ -22,6 +22,8 @@ 整个改动**复用现有 spawn/stdio/timeout/evidence 管线**:新后端本质上只是把 `seatbelt-exec … -- ` 这层 argv 包装换成 `container exec … -- `,加上一套会话级 VM 生命周期与网络协调逻辑。 +本次补充的 `ricccrd/dd` 对照分析不改变上述主路线:`dd` 证明了“无 VM、userspace Linux kernel + Docker Engine API”的轻量方向在 macOS 上有性能与开发体验价值,但它默认仍是同宿主内核的 JIT 进程,`DDJIT_UNTRUSTED` sentry split 也处于早期能力阶段。结论是:**短期继续以 Apple `container` VM 作为不可信 agent 的强隔离后端;中期先吸收 `dd` 的 typed launch contract、path-jail/overlay/network/resource/test-oracle 方法;长期再评估 `MacosDdJit` 作为 trusted/fast 或 VM 内加速后端**(详见 §15.1)。 + --- ## 1. 目标与范围 @@ -735,6 +737,100 @@ container stop dev-box && container rm dev-box - **macOS 15 兜底**:若有强需求,针对 0.x 预览的受限网络做降级模式(默认不做)。 - **快照/检查点**:利用 VM 快照做会话级 undo(远期)。 +### 15.1 `ricccrd/dd` 对照改进方案(无 VM userspace-kernel 后端) + +#### 15.1.1 `dd` 功能拆解 + +`dd` 的定位是:在 Apple Silicon macOS 上**不启动 Linux VM**,通过 JIT 翻译 guest 指令并在 userspace 服务 Linux syscall,让普通 Docker CLI 通过 Docker Engine API 驱动容器。仓库按职责拆成: + +| 模块 | 功能 | 对 Libra 的启发 | +|---|---|---| +| `dd-jit/` | C runtime + Rust binding;构建 `linux/aarch64`、`linux/x86_64`、`darwin/aarch64` 三类 engine;`Guest` + `SpawnConfig` 是前端唯一 launch contract。 | Libra 不应让每个后端散落解析 env/argv;先抽一个 typed `SandboxLaunchSpec`,再由 seatbelt/bwrap/apple-container/dd-jit 各自降低到 argv。 | +| `dd-daemon/` | 实现 Docker Engine API v1.43:image/container/exec/logs/archive/volume/network/build/events/system 等路径;Unix socket + Docker CLI 兼容。 | `libra code` 不需要实现 Docker API,但 `libra sandbox` 可以借鉴“状态/生命周期/API 与执行引擎分层”,把 status/evidence 做成后端无关的诊断面。 | +| `SpawnConfig` | 类型化描述 rootfs、lower layers、volumes、publish、netns、hostname、memory/pids/cpus、uid/gid、guest env、argv,然后生成 engine 命令。 | Libra 当前 `CommandSpec` 偏“运行一条命令”;VM/未来 userspace-kernel 后端需要补一层“会话容器/沙箱 launch spec”。 | +| VFS / overlay | userspace path-jail、copy-up、`.wh.` whiteout、merged `getdents`、read-only bind mount、rootfs read-only;daemon 侧 `archive_host_path` / `overlay_host_path` 与 JIT 路由保持一致。 | Libra 的 worktree 挂载、`apply_patch` 与 shell 互见、deny_read/writable roots 都应有统一的 host↔guest 路径映射与一致性测试,而不是只在 Apple `container` 分支临时处理。 | +| network | `DD_NETNS` 私有 loopback、`DD_NET_ISOLATE` null network、user-defined network 的 IPAM/embedded DNS/host port forwarder。 | Libra 的 `Denied/Allowlist/Full` 要变成后端能力矩阵:是否能硬阻断出网、是否只能 env 提示、代理能否被绕过,必须在 status/evidence 中直出。 | +| resource | `DD_MEM_MAX`、`DD_PIDS_MAX`、`DD_CPUS`、`DD_ROOTFS_RO`、`DD_ULIMITS` 映射 Docker 资源语义。 | `libra code` 的 shell 工具需要资源上限:内存、进程数、CPU、日志缓冲、超时统一进入 `SandboxPolicy`/runtime config。 | +| sentry split | `DDJIT_UNTRUSTED` / `DDJIT_SANDBOX`:worker 只跑 JIT/guest compute,deny-default Seatbelt;sentry 持有 host fs/net/proc 权限,通过共享内存 ring 转发 syscall。README 与源码都标注核心 fs syscall 已通、socket/exec/fork 等仍在 landing。 | 这是未来轻量后端的安全形态,但**不能作为当前不可信 agent 的主隔离边界**;只有 sentry syscall 覆盖、Seatbelt profile、escape 测试和 oracle 矩阵成熟后才能进入 `Required`。 | +| tests | engine × case 矩阵、native oracle diff、real Docker oracle、PTY 字节级 diff、overlay differential、LTP compliance。 | Libra sandbox 测试不能只断言 argv;需要后端行为 oracle:文件、PTY、env、network、resource、timeout、cleanup 与跨后端 parity。 | + +#### 15.1.2 对当前 Apple `container` 主路线的修正 + +1. **不把 `dd` 直接替代 Apple `container` VM**。`dd` 的默认 trusted 路径仍在宿主进程内服务 syscall,安全边界弱于独立 Linux 内核 + VMM;`DDJIT_UNTRUSTED` 的 sentry split 还不是完整 syscall 面。`libra code` 跑不可信依赖/自动批准命令时,默认仍应选择 VM。 +2. **提前抽 `SandboxLaunchSpec`,不要等 Phase 8 才抽 trait**。§6.4 原计划把 `trait SandboxBackend` 放到未来工作;`dd` 的 `SpawnConfig` 说明 launch contract 本身比 backend trait 更重要。Phase 1 应至少新增内部结构: + + ```rust + pub struct SandboxLaunchSpec { + pub session_id: String, + pub host_worktree: PathBuf, + pub guest_worktree: PathBuf, + pub cwd: PathBuf, + pub argv: Vec, + pub env: BTreeMap, + pub writable_roots: Vec, + pub deny_read_paths: Vec, + pub network: NetworkAccess, + pub resources: SandboxResourceLimits, + pub isolation: IsolationStrength, + } + ``` + + `CommandSpec -> SandboxLaunchSpec -> backend argv` 成为新链路;seatbelt/bwrap 路径也走同一 spec,避免 VM 分支单独维护路径/env 规则。 +3. **把资源限制纳入一等配置**。参考 `dd` 的 memory/pids/cpus/ulimit/rootfs-ro,给 `.libra/sandbox.toml [sandbox.resources]` 增: + - `memory_mb` + - `pids_max` + - `cpu_count` + - `stdout_stderr_bytes` + - `rootfs_read_only` + + v1 可只在 Apple `container` 与 Linux bwrap/seccomp 上强制,seatbelt 无法强制的字段在 `libra sandbox status --json` 中标 `enforced=false`。 +4. **把“文件一致性”从 VM 专项提升为全局 invariant**。`dd` 用 `DD_FSGEN_FILE` 解决 daemon-side 写入与 JIT VFS cache 的一致性;Libra 目前没有 JIT VFS cache,但同样存在 `apply_patch` 宿主写、shell 后端读写、future dd-jit 读写三方一致性。新增 `SandboxFsCoherence` 测试族: + - 宿主 `apply_patch` 后,后端 shell 立即读到。 + - 后端 shell 写文件后,宿主 `libra diff/status` 立即看到。 + - rename、symlink、chmod、nested writable root、deny_read 路径都覆盖。 + - 若未来 `MacosDdJit` 引入路径缓存,必须有类似 `fsgen` 的 external-writer epoch。 +5. **把 backend capability 暴露给用户**。`libra sandbox status --json` 追加: + - `isolation_boundary`: `profile|namespace|vm|userspace-kernel|none` + - `host_kernel_shared`: bool + - `network_enforcement`: `kernel|namespace|vm-network|proxy-only|env-only|none` + - `fs_model`: `host-path-jail|bind-mount|virtiofs|overlay|none` + - `resource_controls`: 每项 `{requested,enforced,reason}` + - `trusted_for_required`: bool + + 这样 `dd`、seatbelt、bwrap、Apple VM 的强弱不会被同一个 `sandbox_type` 字符串掩盖。 + +#### 15.1.3 可落地任务卡 + +| 任务 | 改动 | 验收 | +|---|---|---| +| **DD-0 源码事实锁定** | 在本节引用的 `dd` 基线固定为 commit `9b950a2`;记录功能边界:Docker Engine API、JIT/userspace syscall、path-jail、overlay、netns、resource、sentry early state。 | 文档不再把 `dd` 描述成 VM,也不把 sentry 描述成完整 untrusted 方案。 | +| **DD-1 `SandboxLaunchSpec`** | 新增 typed launch contract;`SandboxManager::transform` 先构造 spec,再由 seatbelt/bwrap/apple-container lowering。 | 现有 seatbelt/Linux 单测逐字节回归;VM path/env 过滤测试复用同一 spec。 | +| **DD-2 capability/status** | 为每个后端填 `SandboxBackendCapabilities` 并进入 `libra sandbox status --json`。 | 非 macOS、seatbelt、Linux bwrap、Apple VM fake runner 均能输出完整 capability;Required 决策只看 capability,不靠字符串。 | +| **DD-3 资源限制** | `.libra/sandbox.toml [sandbox.resources]` + env/flag 覆盖;贯穿到 Apple `container run --memory/--cpus`、Linux bwrap/rlimit、shell stdout/stderr cap。 | L1 fake runner 断言 lowering;L3 真机跑 memory/pids/CPU/log cap;未强制字段报告 `enforced=false`。 | +| **DD-4 sandbox oracle 测试矩阵** | 新增 `tests/sandbox_backend_parity_test.rs` 与 fixtures:fs, env, pty, network, resource, timeout, cleanup。参考 `dd-tests` 的 oracle 思路,使用 host/native 行为或 Docker/Apple VM 作为 ground truth。 | 每个后端至少跑 L1 fake + 本机可用后端;PTY 用真实 pseudo-terminal;Denied/Allowlist/Full 有绕过尝试。 | +| **DD-5 `MacosDdJit` spike(非产品)** | 只做实验后端,二选一:A. shell out 到 `dd-daemon`/Docker API;B. 直接依赖 `ddjit::SpawnConfig`(需评估 vendoring、MIT license、codesign/MAP_JIT entitlement、C toolchain)。 | 产出 spike findings:启动延迟、Rust/Cargo 构建可用性、path jail 逃逸测试、resource enforcement、network deny、sentry 覆盖缺口。未通过前不得进入 `auto` 或 `Required`。 | +| **DD-6 sentry 安全闸门** | 若 DD-5 继续,定义 sentry 必过项:fs/net/proc/exec/fork/thread/mmap/socket/fd-passing 覆盖、Seatbelt deny-default profile、no host fd in worker、ring bounds、fuzz/differential/LTP。 | 只有全部通过且有 escape regression tests,`MacosDdJit` 才能标 `trusted_for_required=true`;否则仅允许 `BestEffort` / trusted workload。 | + +#### 15.1.4 与现有里程碑的关系 + +- **M0-M6 仍以 Apple `container` VM 交付为主**;`dd` 不阻塞 VM 后端落地。 +- **DD-1/DD-2 应前置到 M1/M2**:它们降低 Apple VM 实现风险,并为未来后端留干净接口。 +- **DD-3/DD-4 可并行进入 M3/M4**:资源限制与 oracle 测试会直接提升 Apple VM 方案质量。 +- **DD-5/DD-6 是远期探索**:只有当用户明确需要“无 VM、更快、更省 RAM”的 trusted 后端,且 `dd` sentry 成熟后,再考虑产品化。 + +#### 15.1.5 最小产品决策 + +短期产品形态保持: + +```text +default macOS backend: seatbelt +explicit strong backend: apple-container +future fast/trusted backend: macos-dd-jit (spike only) +untrusted automatic backend: apple-container only +``` + +也就是说,`dd` 给 Libra 的第一批改进不是“马上跑一个 JIT Linux kernel”,而是把沙箱接口、资源、文件一致性、网络强制力、诊断和测试 oracle 做到足够严谨。等这些基础变成稳定 contract 后,才有条件接入 `dd` 这样的 userspace-kernel 后端。 + --- ## 16. 里程碑、测试矩阵与验收 @@ -774,6 +870,7 @@ container stop dev-box && container rm dev-box - apple/containerization README、`vminitd/`、DeepWiki(VM management) - WWDC 2025 session 346(Meet Containerization) - 第三方技术解读(the New Stack、4sysops、addozhang、anil.recoil.org、kubeace)——延迟/网络/限制的非官方实测,仅作旁证 +- ricccrd/dd README、`dd-jit/src/lib.rs`、`dd-daemon/src/{main.rs,runtime.rs,model.rs,archive.rs,networks.rs,util.rs}`、`dd-jit/src/runtime/os/linux/{sentry.c,container/vfs/resolve.c}`、`dd-tests/` oracle/scenario/PTY/overlay suites(基线 commit `9b950a2`) > 带 *UNVERIFIED* 的条目(绑挂传输方式、headless launchd、虚拟化授权细节、macOS 15 兜底价值、`container machine` 适用性)必须在 Phase 0 spike 用真机落实,再据实更新本文档。 diff --git a/web/package.json b/web/package.json index 7bf834653..fe15dfc9f 100644 --- a/web/package.json +++ b/web/package.json @@ -33,7 +33,7 @@ "typescript": "^5.9.3", "vitest": "^4.1.6" }, - "packageManager": "pnpm@11.1.0", + "packageManager": "pnpm@11.10.0", "engines": { "node": ">=22.13.0 <23 || >=23.4.0" } From 2b34956d0a0311a6858486f1638795031254ffd1 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Tue, 7 Jul 2026 10:13:06 +0800 Subject: [PATCH 02/12] fix(add): handle case-renamed tracked paths Signed-off-by: Eli Ma --- src/command/add.rs | 148 ++++++++++++++++---------- src/command/status.rs | 42 +++++++- src/command/status_untracked.rs | 14 ++- src/command/status_untracked_paths.rs | 66 +++++++++++- src/utils/path_case.rs | 46 ++++++++ tests/command/case_handling_test.rs | 54 ++++++++++ 6 files changed, 303 insertions(+), 67 deletions(-) diff --git a/src/command/add.rs b/src/command/add.rs index a7288ceda..8dcc42e45 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -345,6 +345,13 @@ struct ValidatedPathspecs { missing: Vec, } +#[derive(Clone, Copy)] +struct PathspecMatchContext<'a> { + workdir: &'a Path, + current_dir: &'a Path, + ignore_case: bool, +} + // --------------------------------------------------------------------------- // Public entry points // --------------------------------------------------------------------------- @@ -508,6 +515,16 @@ pub async fn run_add(args: &AddArgs) -> CliResult { source, })?; let current_dir = env::current_dir().map_err(|source| AddError::Workdir { source })?; + let ignore_case = crate::utils::path_case::effective_ignore_case() + .await + .map_err(|error| { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + })?; + let pathspec_ctx = PathspecMatchContext { + workdir: &workdir, + current_dir: ¤t_dir, + ignore_case, + }; let (mut visible_changes, mut ignored_changes) = if args.force { status::changes_to_be_staged_split_force().map_err(|source| AddError::Status { source })? @@ -522,8 +539,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { let validated = validate_pathspecs( &args.pathspec, &requested_paths, - &workdir, - ¤t_dir, + pathspec_ctx, &visible_changes, &ignored_changes, &index, @@ -544,12 +560,8 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // --- Refresh mode --- if args.refresh { - let tracked_modified = filter_refresh_candidates( - &visible_changes.modified, - &validated.files, - &workdir, - ¤t_dir, - ); + let tracked_modified = + filter_refresh_candidates(&visible_changes.modified, &validated.files, pathspec_ctx); if args.dry_run { add_output.refreshed = tracked_modified .iter() @@ -573,19 +585,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // `--renormalize` operates on the tracked set (implies `-u`) and force-rewrites // each matched blob; the regular path collects working-tree changes. let mut files = if args.renormalize { - filter_candidates( - &index.tracked_files(), - &validated.files, - &workdir, - ¤t_dir, - ) + filter_candidates(&index.tracked_files(), &validated.files, pathspec_ctx) } else { let mut f = visible_changes.modified; f.extend(visible_changes.deleted); if !args.update { f.extend(visible_changes.new); } - filter_candidates(&f, &validated.files, &workdir, ¤t_dir) + filter_candidates(&f, &validated.files, pathspec_ctx) }; filter_out_current_executable(&mut files); files.sort(); @@ -669,8 +676,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, true, &mut add_output, )?; @@ -684,11 +690,6 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // (`core.casehandling=error`) the whole add refuses BEFORE mutating the // index; `warn`/`allow` skip the colliding candidates (staging under the // existing casing is the engine's job — v1 skips, documented). - let ignore_case = crate::utils::path_case::effective_ignore_case() - .await - .map_err(|error| { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) - })?; let files = if ignore_case { let policy = crate::utils::path_case::case_handling_from_config() .await @@ -710,6 +711,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult { let text = crate::utils::util::path_to_string(&file); match tracked_fold.get(&crate::utils::path_case::fold_path_key(&text)) { Some(existing) if existing != &text => { + let existing_path = PathBuf::from(existing); + if crate::utils::path_case::is_same_file_case_alias( + &workdir, + &file, + &existing_path, + ) { + continue; + } collisions.push((text, existing.clone())); } _ => kept.push(file), @@ -786,8 +795,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, false, &mut add_output, )?; @@ -825,17 +833,11 @@ fn apply_chmod( index: &mut Index, target_mode: u32, validated_files: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, dry_run: bool, out: &mut AddOutput, ) -> Result<(), AddError> { - let matched = filter_candidates( - &index.tracked_files(), - validated_files, - workdir, - current_dir, - ); + let matched = filter_candidates(&index.tracked_files(), validated_files, pathspec_ctx); for file in &matched { let file_str = file .to_str() @@ -850,7 +852,7 @@ fn apply_chmod( if current_mode & 0o170000 != 0o100000 || current_mode == target_mode { continue; } - let file_abs = workdir.join(file); + let file_abs = pathspec_ctx.workdir.join(file); if !file_abs.is_file() { // The tracked path is gone (or is a directory): nothing to chmod. continue; @@ -858,12 +860,13 @@ fn apply_chmod( if !dry_run { // Rebuild the entry from the working-tree stat, keeping the existing // blob (no content change) and forcing the requested mode. - let mut updated = IndexEntry::new_from_file(file, hash, workdir).map_err(|source| { - AddError::CreateIndexEntry { - path: file.to_path_buf(), - source, - } - })?; + let mut updated = + IndexEntry::new_from_file(file, hash, pathspec_ctx.workdir).map_err(|source| { + AddError::CreateIndexEntry { + path: file.to_path_buf(), + source, + } + })?; updated.mode = target_mode; index.update(updated); } @@ -1155,8 +1158,7 @@ fn write_err(e: io::Error) -> CliError { fn validate_pathspecs( raw_pathspecs: &[String], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, visible_changes: &Changes, ignored_changes: &Changes, index: &Index, @@ -1178,17 +1180,19 @@ fn validate_pathspecs( let mut files = Vec::new(); let mut missing = Vec::new(); for (raw, requested_path) in raw_pathspecs.iter().zip(requested_paths.iter()) { - let requested_abs = resolve_pathspec(requested_path, current_dir); - if !util::is_sub_path(&requested_abs, workdir) { + let requested_abs = resolve_pathspec(requested_path, pathspec_ctx.current_dir); + if !util::is_sub_path(&requested_abs, pathspec_ctx.workdir) { return Err(AddError::PathOutsideRepo { path: raw.clone(), - repo_root: workdir.to_path_buf(), + repo_root: pathspec_ctx.workdir.to_path_buf(), }); } - let matches_changes = pathspec_matches_any(&requested_abs, &change_candidates, workdir); - let matches_tracked = pathspec_matches_any(&requested_abs, &tracked_files, workdir); - let matches_ignored = pathspec_matches_any(&requested_abs, &ignored_candidates, workdir); + let matches_changes = + pathspec_matches_any(&requested_abs, &change_candidates, pathspec_ctx); + let matches_tracked = pathspec_matches_any(&requested_abs, &tracked_files, pathspec_ctx); + let matches_ignored = + pathspec_matches_any(&requested_abs, &ignored_candidates, pathspec_ctx); if matches_changes || matches_tracked { files.push(requested_path.clone()); @@ -1240,13 +1244,43 @@ fn resolve_pathspec(pathspec: &Path, current_dir: &Path) -> PathBuf { } } +fn pathspec_contains(candidate_abs: &Path, requested_abs: &Path, ignore_case: bool) -> bool { + if util::is_sub_path(candidate_abs, requested_abs) { + return true; + } + ignore_case && path_starts_with_casefold(candidate_abs, requested_abs) +} + +fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { + let mut path_components = path.components(); + for parent_component in parent.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + let path_key = crate::utils::path_case::fold_path_key( + path_component.as_os_str().to_string_lossy().as_ref(), + ); + let parent_key = crate::utils::path_case::fold_path_key( + parent_component.as_os_str().to_string_lossy().as_ref(), + ); + if path_key != parent_key { + return false; + } + } + true +} + /// True iff any path in `candidates` (interpreted relative to `workdir`) is a /// subpath of `requested_abs`. Used both for tracked-file matching and for /// status-change matching. -fn pathspec_matches_any(requested_abs: &Path, candidates: &[PathBuf], workdir: &Path) -> bool { +fn pathspec_matches_any( + requested_abs: &Path, + candidates: &[PathBuf], + pathspec_ctx: PathspecMatchContext<'_>, +) -> bool { candidates.iter().any(|candidate| { - let candidate_abs = workdir.join(candidate); - util::is_sub_path(&candidate_abs, requested_abs) + let candidate_abs = pathspec_ctx.workdir.join(candidate); + pathspec_contains(&candidate_abs, requested_abs, pathspec_ctx.ignore_case) }) } @@ -1256,16 +1290,15 @@ fn pathspec_matches_any(requested_abs: &Path, candidates: &[PathBuf], workdir: & fn filter_candidates( files: &[PathBuf], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, ) -> Vec { files .iter() .filter(|file| { - let file_abs = workdir.join(file.as_path()); + let file_abs = pathspec_ctx.workdir.join(file.as_path()); requested_paths.iter().any(|pathspec| { - let requested_abs = resolve_pathspec(pathspec, current_dir); - util::is_sub_path(&file_abs, &requested_abs) + let requested_abs = resolve_pathspec(pathspec, pathspec_ctx.current_dir); + pathspec_contains(&file_abs, &requested_abs, pathspec_ctx.ignore_case) }) }) .cloned() @@ -1278,10 +1311,9 @@ fn filter_candidates( fn filter_refresh_candidates( files: &[PathBuf], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, ) -> Vec { - filter_candidates(files, requested_paths, workdir, current_dir) + filter_candidates(files, requested_paths, pathspec_ctx) } /// Remove the running `libra` binary from the candidate list. diff --git a/src/command/status.rs b/src/command/status.rs index 5a0c36c47..229ef6815 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, io, io::{IsTerminal, Write}, - path::PathBuf, + path::{Path, PathBuf}, }; use clap::{Parser, ValueEnum}; @@ -2606,6 +2606,7 @@ fn changes_to_be_staged_split_force_with_index( let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); + let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2634,7 +2635,8 @@ fn changes_to_be_staged_split_force_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { visible.new.push(file); } } @@ -2642,7 +2644,8 @@ fn changes_to_be_staged_split_force_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { ignored.new.push(file); } } @@ -2656,6 +2659,7 @@ fn changes_to_be_staged_split_with_index( let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); + let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2683,7 +2687,8 @@ fn changes_to_be_staged_split_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { visible.new.push(file); } } @@ -2691,13 +2696,40 @@ fn changes_to_be_staged_split_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { ignored.new.push(file); } } Ok((visible, ignored)) } +fn tracked_files_by_fold(workdir: &Path, tracked_files: &[PathBuf]) -> HashMap { + if !crate::utils::path_case::probe_dir_ignore_case(workdir) { + return HashMap::new(); + } + tracked_files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect() +} + +fn is_same_file_tracked_alias( + workdir: &Path, + file: &Path, + tracked_fold: &HashMap, +) -> bool { + let key = crate::utils::path_case::fold_path_key(file.to_string_lossy().as_ref()); + tracked_fold.get(&key).is_some_and(|tracked| { + crate::utils::path_case::is_same_file_case_alias(workdir, file, tracked) + }) +} + fn list_workdir_files_split_safe(workdir: &PathBuf) -> io::Result<(Vec, Vec)> { let mut files = Vec::new(); let mut ignored = Vec::new(); diff --git a/src/command/status_untracked.rs b/src/command/status_untracked.rs index f3150aeca..e246c8cd7 100644 --- a/src/command/status_untracked.rs +++ b/src/command/status_untracked.rs @@ -179,7 +179,15 @@ fn scan_workdir( } pending_dirs.push(path); } else if file_type.is_file() { - scan_file(&mut scan, workdir, index, &path, &relative, include_ignored)?; + scan_file( + &mut scan, + workdir, + index, + tracked, + &path, + &relative, + include_ignored, + )?; } } } @@ -191,6 +199,7 @@ fn scan_file( scan: &mut WorkdirScan, workdir: &Path, index: &Index, + tracked_paths: &TrackedPaths, path: &Path, relative: &Path, include_ignored: bool, @@ -200,7 +209,8 @@ fn scan_file( .ok_or_else(|| StatusError::InvalidPathEncoding { path: relative.to_path_buf(), })?; - let tracked = index.tracked(file_str, 0); + let tracked = + index.tracked(file_str, 0) || tracked_paths.same_file_case_alias(workdir, relative); if util::check_gitignore(&workdir.to_path_buf(), &path.to_path_buf()) { if include_ignored && !tracked { scan.ignored.push(relative.to_path_buf()); diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index 9b501b129..f218b13d9 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -7,19 +7,45 @@ use git_internal::internal::index::Index; pub(crate) struct TrackedPaths { files: Vec, + case_aliases_enabled: bool, + files_by_fold: HashMap, top_level_dirs: HashSet, + top_level_dirs_by_fold: HashMap, } impl TrackedPaths { pub(crate) fn from_index(index: &Index) -> Self { let files = index.tracked_files(); + let case_aliases_enabled = crate::utils::path_case::probe_workdir_ignore_case(); let top_level_dirs = files .iter() .filter_map(|path| top_level_dir(path)) .collect(); + let files_by_fold = files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect(); + let top_level_dirs_by_fold = files + .iter() + .filter_map(|path| { + let dir = top_level_dir(path)?; + Some(( + crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + dir, + )) + }) + .collect(); Self { files, + case_aliases_enabled, + files_by_fold, top_level_dirs, + top_level_dirs_by_fold, } } @@ -29,10 +55,46 @@ impl TrackedPaths { pub(crate) fn has_descendant(&self, dir: &Path) -> bool { if is_top_level_path(dir) { - return self.top_level_dirs.contains(dir); + return self.top_level_dirs.contains(dir) + || (self.case_aliases_enabled + && self.top_level_dirs_by_fold.contains_key( + &crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + )); + } + self.files.iter().any(|file| { + file.starts_with(dir) + || (self.case_aliases_enabled && path_starts_with_casefold(file, dir)) + }) + } + + pub(crate) fn same_file_case_alias(&self, workdir: &Path, path: &Path) -> bool { + if !self.case_aliases_enabled { + return false; + } + let key = crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()); + self.files_by_fold.get(&key).is_some_and(|tracked| { + crate::utils::path_case::is_same_file_case_alias(workdir, path, tracked) + }) + } +} + +fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { + let mut path_components = path.components(); + for parent_component in parent.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + let path_key = crate::utils::path_case::fold_path_key( + path_component.as_os_str().to_string_lossy().as_ref(), + ); + let parent_key = crate::utils::path_case::fold_path_key( + parent_component.as_os_str().to_string_lossy().as_ref(), + ); + if path_key != parent_key { + return false; } - self.files.iter().any(|file| file.starts_with(dir)) } + true } pub(crate) fn collapse_untracked_directories( diff --git a/src/utils/path_case.rs b/src/utils/path_case.rs index 949c7d031..045a9da9f 100644 --- a/src/utils/path_case.rs +++ b/src/utils/path_case.rs @@ -123,6 +123,17 @@ pub fn same_file_entry(a: &Path, b: &Path) -> bool { } } +/// Whether `candidate` and `tracked` differ only by case and resolve to the +/// same worktree entry. This is the case-only alias path on case-insensitive +/// filesystems; it is not an index twin. +pub fn is_same_file_case_alias(workdir: &Path, candidate: &Path, tracked: &Path) -> bool { + let candidate_text = candidate.to_string_lossy(); + let tracked_text = tracked.to_string_lossy(); + candidate_text != tracked_text + && fold_path_key(candidate_text.as_ref()) == fold_path_key(tracked_text.as_ref()) + && same_file_entry(&workdir.join(candidate), &workdir.join(tracked)) +} + /// The repo's EFFECTIVE case-insensitivity: explicit `core.ignorecase` /// (git-bool, invalid = hard error) wins; otherwise a per-process runtime /// probe of the workdir; missing workdir → false (guards no-op). @@ -270,4 +281,39 @@ mod tests { ); } } + + #[test] + fn same_file_case_alias_requires_same_entry() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".libra")).unwrap(); + let lower = dir.path().join("slides.txt"); + std::fs::write(&lower, "content").unwrap(); + + if probe_dir_ignore_case(dir.path()) { + assert!(is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + return; + } + + std::fs::write(dir.path().join("Slides.txt"), "other").unwrap(); + assert!(!is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + + #[cfg(unix)] + { + std::fs::remove_file(dir.path().join("Slides.txt")).unwrap(); + std::fs::hard_link(&lower, dir.path().join("Slides.txt")).unwrap(); + assert!(is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + } + } } diff --git a/tests/command/case_handling_test.rs b/tests/command/case_handling_test.rs index 06989cf58..682ecad8c 100644 --- a/tests/command/case_handling_test.rs +++ b/tests/command/case_handling_test.rs @@ -128,6 +128,60 @@ fn add_refuses_case_fold_twins_under_error_default() { assert_cli_success(&run_libra_command(&["add", "other.txt"], p), "clean add"); } +#[test] +fn add_accepts_case_renamed_tracked_directory_alias() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + if !libra::utils::path_case::probe_dir_ignore_case(p) { + eprintln!("skipping case-renamed directory add: host filesystem is case-sensitive"); + return; + } + + fs::create_dir(p.join("slides")).unwrap(); + fs::write(p.join("slides/a.txt"), "one\n").unwrap(); + assert_cli_success( + &run_libra_command(&["add", "slides/a.txt"], p), + "add slides", + ); + assert_cli_success( + &run_libra_command(&["commit", "-m", "slides", "--no-verify"], p), + "commit slides", + ); + + fs::rename(p.join("slides"), p.join("slides-tmp")).unwrap(); + fs::rename(p.join("slides-tmp"), p.join("Slides")).unwrap(); + fs::write(p.join("Slides/a.txt"), "two\n").unwrap(); + + let added = run_libra_command(&["add", "Slides"], p); + assert_cli_success(&added, "add through case-renamed directory"); + + let ls = run_libra_command(&["ls-files"], p); + let listing = String::from_utf8_lossy(&ls.stdout); + assert!(listing.contains("slides/a.txt"), "{listing}"); + assert!(!listing.contains("Slides/a.txt"), "{listing}"); + + let status = run_libra_command(&["--json", "status"], p); + let json = parse_json_stdout(&status); + let staged_modified: Vec = json["data"]["staged"]["modified"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert_eq!(staged_modified, vec!["slides/a.txt".to_string()]); + let unstaged_new: Vec = json["data"]["unstaged"]["new"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert!(!unstaged_new.contains(&"Slides/".to_string()), "{json}"); +} + #[test] fn checkout_switch_refuse_colliding_trees_on_insensitive_view() { let repo = case_repo(); From f5f7c762f61df49d0a03a9b25bbed3d8eafa1909 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 7 Jul 2026 10:17:43 +0800 Subject: [PATCH 03/12] fix(add): handle case-renamed tracked paths Signed-off-by: Quanyi Ma --- src/command/add.rs | 148 ++++++++++++++++---------- src/command/status.rs | 42 +++++++- src/command/status_untracked.rs | 14 ++- src/command/status_untracked_paths.rs | 66 +++++++++++- src/utils/path_case.rs | 46 ++++++++ tests/command/case_handling_test.rs | 54 ++++++++++ 6 files changed, 303 insertions(+), 67 deletions(-) diff --git a/src/command/add.rs b/src/command/add.rs index a7288ceda..8dcc42e45 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -345,6 +345,13 @@ struct ValidatedPathspecs { missing: Vec, } +#[derive(Clone, Copy)] +struct PathspecMatchContext<'a> { + workdir: &'a Path, + current_dir: &'a Path, + ignore_case: bool, +} + // --------------------------------------------------------------------------- // Public entry points // --------------------------------------------------------------------------- @@ -508,6 +515,16 @@ pub async fn run_add(args: &AddArgs) -> CliResult { source, })?; let current_dir = env::current_dir().map_err(|source| AddError::Workdir { source })?; + let ignore_case = crate::utils::path_case::effective_ignore_case() + .await + .map_err(|error| { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + })?; + let pathspec_ctx = PathspecMatchContext { + workdir: &workdir, + current_dir: ¤t_dir, + ignore_case, + }; let (mut visible_changes, mut ignored_changes) = if args.force { status::changes_to_be_staged_split_force().map_err(|source| AddError::Status { source })? @@ -522,8 +539,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { let validated = validate_pathspecs( &args.pathspec, &requested_paths, - &workdir, - ¤t_dir, + pathspec_ctx, &visible_changes, &ignored_changes, &index, @@ -544,12 +560,8 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // --- Refresh mode --- if args.refresh { - let tracked_modified = filter_refresh_candidates( - &visible_changes.modified, - &validated.files, - &workdir, - ¤t_dir, - ); + let tracked_modified = + filter_refresh_candidates(&visible_changes.modified, &validated.files, pathspec_ctx); if args.dry_run { add_output.refreshed = tracked_modified .iter() @@ -573,19 +585,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // `--renormalize` operates on the tracked set (implies `-u`) and force-rewrites // each matched blob; the regular path collects working-tree changes. let mut files = if args.renormalize { - filter_candidates( - &index.tracked_files(), - &validated.files, - &workdir, - ¤t_dir, - ) + filter_candidates(&index.tracked_files(), &validated.files, pathspec_ctx) } else { let mut f = visible_changes.modified; f.extend(visible_changes.deleted); if !args.update { f.extend(visible_changes.new); } - filter_candidates(&f, &validated.files, &workdir, ¤t_dir) + filter_candidates(&f, &validated.files, pathspec_ctx) }; filter_out_current_executable(&mut files); files.sort(); @@ -669,8 +676,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, true, &mut add_output, )?; @@ -684,11 +690,6 @@ pub async fn run_add(args: &AddArgs) -> CliResult { // (`core.casehandling=error`) the whole add refuses BEFORE mutating the // index; `warn`/`allow` skip the colliding candidates (staging under the // existing casing is the engine's job — v1 skips, documented). - let ignore_case = crate::utils::path_case::effective_ignore_case() - .await - .map_err(|error| { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) - })?; let files = if ignore_case { let policy = crate::utils::path_case::case_handling_from_config() .await @@ -710,6 +711,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult { let text = crate::utils::util::path_to_string(&file); match tracked_fold.get(&crate::utils::path_case::fold_path_key(&text)) { Some(existing) if existing != &text => { + let existing_path = PathBuf::from(existing); + if crate::utils::path_case::is_same_file_case_alias( + &workdir, + &file, + &existing_path, + ) { + continue; + } collisions.push((text, existing.clone())); } _ => kept.push(file), @@ -786,8 +795,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, false, &mut add_output, )?; @@ -825,17 +833,11 @@ fn apply_chmod( index: &mut Index, target_mode: u32, validated_files: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, dry_run: bool, out: &mut AddOutput, ) -> Result<(), AddError> { - let matched = filter_candidates( - &index.tracked_files(), - validated_files, - workdir, - current_dir, - ); + let matched = filter_candidates(&index.tracked_files(), validated_files, pathspec_ctx); for file in &matched { let file_str = file .to_str() @@ -850,7 +852,7 @@ fn apply_chmod( if current_mode & 0o170000 != 0o100000 || current_mode == target_mode { continue; } - let file_abs = workdir.join(file); + let file_abs = pathspec_ctx.workdir.join(file); if !file_abs.is_file() { // The tracked path is gone (or is a directory): nothing to chmod. continue; @@ -858,12 +860,13 @@ fn apply_chmod( if !dry_run { // Rebuild the entry from the working-tree stat, keeping the existing // blob (no content change) and forcing the requested mode. - let mut updated = IndexEntry::new_from_file(file, hash, workdir).map_err(|source| { - AddError::CreateIndexEntry { - path: file.to_path_buf(), - source, - } - })?; + let mut updated = + IndexEntry::new_from_file(file, hash, pathspec_ctx.workdir).map_err(|source| { + AddError::CreateIndexEntry { + path: file.to_path_buf(), + source, + } + })?; updated.mode = target_mode; index.update(updated); } @@ -1155,8 +1158,7 @@ fn write_err(e: io::Error) -> CliError { fn validate_pathspecs( raw_pathspecs: &[String], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, visible_changes: &Changes, ignored_changes: &Changes, index: &Index, @@ -1178,17 +1180,19 @@ fn validate_pathspecs( let mut files = Vec::new(); let mut missing = Vec::new(); for (raw, requested_path) in raw_pathspecs.iter().zip(requested_paths.iter()) { - let requested_abs = resolve_pathspec(requested_path, current_dir); - if !util::is_sub_path(&requested_abs, workdir) { + let requested_abs = resolve_pathspec(requested_path, pathspec_ctx.current_dir); + if !util::is_sub_path(&requested_abs, pathspec_ctx.workdir) { return Err(AddError::PathOutsideRepo { path: raw.clone(), - repo_root: workdir.to_path_buf(), + repo_root: pathspec_ctx.workdir.to_path_buf(), }); } - let matches_changes = pathspec_matches_any(&requested_abs, &change_candidates, workdir); - let matches_tracked = pathspec_matches_any(&requested_abs, &tracked_files, workdir); - let matches_ignored = pathspec_matches_any(&requested_abs, &ignored_candidates, workdir); + let matches_changes = + pathspec_matches_any(&requested_abs, &change_candidates, pathspec_ctx); + let matches_tracked = pathspec_matches_any(&requested_abs, &tracked_files, pathspec_ctx); + let matches_ignored = + pathspec_matches_any(&requested_abs, &ignored_candidates, pathspec_ctx); if matches_changes || matches_tracked { files.push(requested_path.clone()); @@ -1240,13 +1244,43 @@ fn resolve_pathspec(pathspec: &Path, current_dir: &Path) -> PathBuf { } } +fn pathspec_contains(candidate_abs: &Path, requested_abs: &Path, ignore_case: bool) -> bool { + if util::is_sub_path(candidate_abs, requested_abs) { + return true; + } + ignore_case && path_starts_with_casefold(candidate_abs, requested_abs) +} + +fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { + let mut path_components = path.components(); + for parent_component in parent.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + let path_key = crate::utils::path_case::fold_path_key( + path_component.as_os_str().to_string_lossy().as_ref(), + ); + let parent_key = crate::utils::path_case::fold_path_key( + parent_component.as_os_str().to_string_lossy().as_ref(), + ); + if path_key != parent_key { + return false; + } + } + true +} + /// True iff any path in `candidates` (interpreted relative to `workdir`) is a /// subpath of `requested_abs`. Used both for tracked-file matching and for /// status-change matching. -fn pathspec_matches_any(requested_abs: &Path, candidates: &[PathBuf], workdir: &Path) -> bool { +fn pathspec_matches_any( + requested_abs: &Path, + candidates: &[PathBuf], + pathspec_ctx: PathspecMatchContext<'_>, +) -> bool { candidates.iter().any(|candidate| { - let candidate_abs = workdir.join(candidate); - util::is_sub_path(&candidate_abs, requested_abs) + let candidate_abs = pathspec_ctx.workdir.join(candidate); + pathspec_contains(&candidate_abs, requested_abs, pathspec_ctx.ignore_case) }) } @@ -1256,16 +1290,15 @@ fn pathspec_matches_any(requested_abs: &Path, candidates: &[PathBuf], workdir: & fn filter_candidates( files: &[PathBuf], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, ) -> Vec { files .iter() .filter(|file| { - let file_abs = workdir.join(file.as_path()); + let file_abs = pathspec_ctx.workdir.join(file.as_path()); requested_paths.iter().any(|pathspec| { - let requested_abs = resolve_pathspec(pathspec, current_dir); - util::is_sub_path(&file_abs, &requested_abs) + let requested_abs = resolve_pathspec(pathspec, pathspec_ctx.current_dir); + pathspec_contains(&file_abs, &requested_abs, pathspec_ctx.ignore_case) }) }) .cloned() @@ -1278,10 +1311,9 @@ fn filter_candidates( fn filter_refresh_candidates( files: &[PathBuf], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, ) -> Vec { - filter_candidates(files, requested_paths, workdir, current_dir) + filter_candidates(files, requested_paths, pathspec_ctx) } /// Remove the running `libra` binary from the candidate list. diff --git a/src/command/status.rs b/src/command/status.rs index 5a0c36c47..229ef6815 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, io, io::{IsTerminal, Write}, - path::PathBuf, + path::{Path, PathBuf}, }; use clap::{Parser, ValueEnum}; @@ -2606,6 +2606,7 @@ fn changes_to_be_staged_split_force_with_index( let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); + let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2634,7 +2635,8 @@ fn changes_to_be_staged_split_force_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { visible.new.push(file); } } @@ -2642,7 +2644,8 @@ fn changes_to_be_staged_split_force_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { ignored.new.push(file); } } @@ -2656,6 +2659,7 @@ fn changes_to_be_staged_split_with_index( let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); + let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2683,7 +2687,8 @@ fn changes_to_be_staged_split_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { visible.new.push(file); } } @@ -2691,13 +2696,40 @@ fn changes_to_be_staged_split_with_index( let file_str = file .to_str() .ok_or_else(|| StatusError::InvalidPathEncoding { path: file.clone() })?; - if !index.tracked(file_str, 0) { + if !index.tracked(file_str, 0) && !is_same_file_tracked_alias(workdir, &file, &tracked_fold) + { ignored.new.push(file); } } Ok((visible, ignored)) } +fn tracked_files_by_fold(workdir: &Path, tracked_files: &[PathBuf]) -> HashMap { + if !crate::utils::path_case::probe_dir_ignore_case(workdir) { + return HashMap::new(); + } + tracked_files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect() +} + +fn is_same_file_tracked_alias( + workdir: &Path, + file: &Path, + tracked_fold: &HashMap, +) -> bool { + let key = crate::utils::path_case::fold_path_key(file.to_string_lossy().as_ref()); + tracked_fold.get(&key).is_some_and(|tracked| { + crate::utils::path_case::is_same_file_case_alias(workdir, file, tracked) + }) +} + fn list_workdir_files_split_safe(workdir: &PathBuf) -> io::Result<(Vec, Vec)> { let mut files = Vec::new(); let mut ignored = Vec::new(); diff --git a/src/command/status_untracked.rs b/src/command/status_untracked.rs index f3150aeca..e246c8cd7 100644 --- a/src/command/status_untracked.rs +++ b/src/command/status_untracked.rs @@ -179,7 +179,15 @@ fn scan_workdir( } pending_dirs.push(path); } else if file_type.is_file() { - scan_file(&mut scan, workdir, index, &path, &relative, include_ignored)?; + scan_file( + &mut scan, + workdir, + index, + tracked, + &path, + &relative, + include_ignored, + )?; } } } @@ -191,6 +199,7 @@ fn scan_file( scan: &mut WorkdirScan, workdir: &Path, index: &Index, + tracked_paths: &TrackedPaths, path: &Path, relative: &Path, include_ignored: bool, @@ -200,7 +209,8 @@ fn scan_file( .ok_or_else(|| StatusError::InvalidPathEncoding { path: relative.to_path_buf(), })?; - let tracked = index.tracked(file_str, 0); + let tracked = + index.tracked(file_str, 0) || tracked_paths.same_file_case_alias(workdir, relative); if util::check_gitignore(&workdir.to_path_buf(), &path.to_path_buf()) { if include_ignored && !tracked { scan.ignored.push(relative.to_path_buf()); diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index 9b501b129..f218b13d9 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -7,19 +7,45 @@ use git_internal::internal::index::Index; pub(crate) struct TrackedPaths { files: Vec, + case_aliases_enabled: bool, + files_by_fold: HashMap, top_level_dirs: HashSet, + top_level_dirs_by_fold: HashMap, } impl TrackedPaths { pub(crate) fn from_index(index: &Index) -> Self { let files = index.tracked_files(); + let case_aliases_enabled = crate::utils::path_case::probe_workdir_ignore_case(); let top_level_dirs = files .iter() .filter_map(|path| top_level_dir(path)) .collect(); + let files_by_fold = files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect(); + let top_level_dirs_by_fold = files + .iter() + .filter_map(|path| { + let dir = top_level_dir(path)?; + Some(( + crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + dir, + )) + }) + .collect(); Self { files, + case_aliases_enabled, + files_by_fold, top_level_dirs, + top_level_dirs_by_fold, } } @@ -29,10 +55,46 @@ impl TrackedPaths { pub(crate) fn has_descendant(&self, dir: &Path) -> bool { if is_top_level_path(dir) { - return self.top_level_dirs.contains(dir); + return self.top_level_dirs.contains(dir) + || (self.case_aliases_enabled + && self.top_level_dirs_by_fold.contains_key( + &crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + )); + } + self.files.iter().any(|file| { + file.starts_with(dir) + || (self.case_aliases_enabled && path_starts_with_casefold(file, dir)) + }) + } + + pub(crate) fn same_file_case_alias(&self, workdir: &Path, path: &Path) -> bool { + if !self.case_aliases_enabled { + return false; + } + let key = crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()); + self.files_by_fold.get(&key).is_some_and(|tracked| { + crate::utils::path_case::is_same_file_case_alias(workdir, path, tracked) + }) + } +} + +fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { + let mut path_components = path.components(); + for parent_component in parent.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + let path_key = crate::utils::path_case::fold_path_key( + path_component.as_os_str().to_string_lossy().as_ref(), + ); + let parent_key = crate::utils::path_case::fold_path_key( + parent_component.as_os_str().to_string_lossy().as_ref(), + ); + if path_key != parent_key { + return false; } - self.files.iter().any(|file| file.starts_with(dir)) } + true } pub(crate) fn collapse_untracked_directories( diff --git a/src/utils/path_case.rs b/src/utils/path_case.rs index 949c7d031..045a9da9f 100644 --- a/src/utils/path_case.rs +++ b/src/utils/path_case.rs @@ -123,6 +123,17 @@ pub fn same_file_entry(a: &Path, b: &Path) -> bool { } } +/// Whether `candidate` and `tracked` differ only by case and resolve to the +/// same worktree entry. This is the case-only alias path on case-insensitive +/// filesystems; it is not an index twin. +pub fn is_same_file_case_alias(workdir: &Path, candidate: &Path, tracked: &Path) -> bool { + let candidate_text = candidate.to_string_lossy(); + let tracked_text = tracked.to_string_lossy(); + candidate_text != tracked_text + && fold_path_key(candidate_text.as_ref()) == fold_path_key(tracked_text.as_ref()) + && same_file_entry(&workdir.join(candidate), &workdir.join(tracked)) +} + /// The repo's EFFECTIVE case-insensitivity: explicit `core.ignorecase` /// (git-bool, invalid = hard error) wins; otherwise a per-process runtime /// probe of the workdir; missing workdir → false (guards no-op). @@ -270,4 +281,39 @@ mod tests { ); } } + + #[test] + fn same_file_case_alias_requires_same_entry() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".libra")).unwrap(); + let lower = dir.path().join("slides.txt"); + std::fs::write(&lower, "content").unwrap(); + + if probe_dir_ignore_case(dir.path()) { + assert!(is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + return; + } + + std::fs::write(dir.path().join("Slides.txt"), "other").unwrap(); + assert!(!is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + + #[cfg(unix)] + { + std::fs::remove_file(dir.path().join("Slides.txt")).unwrap(); + std::fs::hard_link(&lower, dir.path().join("Slides.txt")).unwrap(); + assert!(is_same_file_case_alias( + dir.path(), + Path::new("Slides.txt"), + Path::new("slides.txt") + )); + } + } } diff --git a/tests/command/case_handling_test.rs b/tests/command/case_handling_test.rs index 06989cf58..682ecad8c 100644 --- a/tests/command/case_handling_test.rs +++ b/tests/command/case_handling_test.rs @@ -128,6 +128,60 @@ fn add_refuses_case_fold_twins_under_error_default() { assert_cli_success(&run_libra_command(&["add", "other.txt"], p), "clean add"); } +#[test] +fn add_accepts_case_renamed_tracked_directory_alias() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + if !libra::utils::path_case::probe_dir_ignore_case(p) { + eprintln!("skipping case-renamed directory add: host filesystem is case-sensitive"); + return; + } + + fs::create_dir(p.join("slides")).unwrap(); + fs::write(p.join("slides/a.txt"), "one\n").unwrap(); + assert_cli_success( + &run_libra_command(&["add", "slides/a.txt"], p), + "add slides", + ); + assert_cli_success( + &run_libra_command(&["commit", "-m", "slides", "--no-verify"], p), + "commit slides", + ); + + fs::rename(p.join("slides"), p.join("slides-tmp")).unwrap(); + fs::rename(p.join("slides-tmp"), p.join("Slides")).unwrap(); + fs::write(p.join("Slides/a.txt"), "two\n").unwrap(); + + let added = run_libra_command(&["add", "Slides"], p); + assert_cli_success(&added, "add through case-renamed directory"); + + let ls = run_libra_command(&["ls-files"], p); + let listing = String::from_utf8_lossy(&ls.stdout); + assert!(listing.contains("slides/a.txt"), "{listing}"); + assert!(!listing.contains("Slides/a.txt"), "{listing}"); + + let status = run_libra_command(&["--json", "status"], p); + let json = parse_json_stdout(&status); + let staged_modified: Vec = json["data"]["staged"]["modified"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert_eq!(staged_modified, vec!["slides/a.txt".to_string()]); + let unstaged_new: Vec = json["data"]["unstaged"]["new"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert!(!unstaged_new.contains(&"Slides/".to_string()), "{json}"); +} + #[test] fn checkout_switch_refuse_colliding_trees_on_insensitive_view() { let repo = case_repo(); From c9dba581cb44c97fbed998f569810d4145e3aecf Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Tue, 7 Jul 2026 10:36:02 +0800 Subject: [PATCH 04/12] docs(tracing): add section 11 unfinished-item completion audit (2026-07-07) Signed-off-by: Eli Ma --- docs/development/tracing/plan.md | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/development/tracing/plan.md b/docs/development/tracing/plan.md index 1c693e2b9..18da36658 100644 --- a/docs/development/tracing/plan.md +++ b/docs/development/tracing/plan.md @@ -1606,3 +1606,44 @@ cargo test --lib cli::tests::root_after_help_lists_every_visible_command | 2026-07-06 | Task C5(Session resume, graph handoff and persistence) | 完成 | 0.18.17 | 本行所在提交 | **GAP-2 决策落地**:按卡 :1309 + tracing code.md:48 保持 --resume TUI-only(web-only/stdio 均拒绝,不放宽),把 C2 遗留的「deferred to C5」文档改写为「TUI-only by design(永久契约,非延后)」。逐 criterion:**(1) --resume TUI-only + 错误测试**:web-only reject 已有 rejects_tui_flags_in_web_mode(消息含 flag+--web+remove),补 stdio 侧 rejects_resume_in_stdio_mode;code_resume_test(test-provider)4 真实 PTY 用例(happy/SIGTERM-mid-turn/unknown-uuid/unknown-non-uuid)全绿。**dead code**:load_or_create_headless_web_session_state 的 resume 分支经 --resume upstream 拒绝后 CLI 不可达,但 create 分支载荷可达——保留+文档注明「intentionally unreachable,TUI-only by design」(移除需重塑 helper 无收益)。**(3) graph handoff --repo 提示**(GAP-FIX):TUI exit 打印原只发 `libra graph ` 无 --repo,违 code.md:24 承诺;新 format_graph_handoff_hint(session_working_dir≠cwd 时 canonicalize 比较后追加 --repo,cwd 未知时 fail-safe 加 --repo)+ shell_quote_for_display(含空格/shell 元字符时 POSIX 单引号,`'`→`'\''`,否则裸值)+ 5 测试(同目录裸/远程加 --repo/未知 cwd 加 --repo/引用 helper 纯测/含空格路径加引号)。**(4) JSONL reader**:parse_session_event_value 跳过未知/缺 kind(Ok(None)),load_events 容忍尾行 truncated(无尾换行→warn+break)但对完整畸形行仍报错——ai_session_jsonl_test 4 绿。**(5) projection bundle identity**:run_tui_with_model_inner 按 canonical thread id 载 bundle,build_tui_code_ui_runtime 用 bundle identity,仅 None 时回退临时 session.id——tui_code_ui_runtime_prefers_projection_bundle_identity + ai_code_ui_projection_test 绿。**(6) audit sink**:web/mod.rs 经 runtime AuditSink 发 local-tui-control::/policy_version=local-tui-control/v1 的结构化 redacted ControlAuditRecord(非 transcript),TUI 路由 TracingAuditSink——InMemoryAuditSink control-attach 测试 pin。文档:code.md:117/241 + zh-CN + tracing code.md:26/75 全部从「deferred/lands later」改为「TUI-only by design」,移除 web-only --resume 示例。codex review 两轮:R1 FAIL 2P2(--repo 路径未 shell-quote 破坏含空格路径复制粘贴;tracing code.md 第二处 stale「web-only --resume→C5」)→修(shell_quote_for_display + 2 测试 + 修 stale 行)→R2 VERDICT: PASS 零发现。门禁:fmt/clippy --all-targets --all-features -D warnings/code:: 91 单测/code_resume_test 11(test-provider)/ai_session_jsonl 4/ai_code_ui_projection 2/ai_goal_resume 3 全绿 | | 2026-07-06 | Task C6(MCP stdio and code-control boundary) | 完成 | 0.18.17 | 本行所在提交 | 验证型卡(C1 判定该面已一致)。逐 criterion:**(1) `libra code --stdio` = MCP-only 不控 live TUI(satisfied-as-is)**:execute_stdio(code.rs:4076-4103)只 init_mcp_server + serve_server(AsyncRwTransport stdio),无 TUI/AgentRuntime attach;`--control write` 在 `--stdio` 下被 validate_mode_args 拒绝并指向 `code-control --stdio`(code.rs:4160-4166);unit pin:rejects_control_write_in_stdio_mode(断言含「code-control --stdio」)、stdio_mode_stays_provider_locked、rejects_web_flags_in_stdio_mode、rejects_resume_in_stdio_mode。新增的 stdio 集成测试进一步实证运行期只走 MCP 传输。**(2) `code-control --stdio` = token/lease-gated automation 入口(satisfied-as-is)**:execute 要求 `--stdio`(code_control.rs:144);controller.attach 用 process control token(x-libra-control-token)换取 controller/lease token,message.submit/interaction.respond/turn.cancel/task.dispatch/goal.start/goal.cancel 同时转发两枚 token(Some(&controller_token),code_control.rs:289-393);json_rpc_dispatch_maps_attach_submit_and_detach_to_http 固定 token 转发;server 端强制(--control observe→403 CONTROL_DISABLED、messages-route-before-controller-token inline)由 code_ui_remote_security_matrix 守卫。**(3) docs 不把 MCP stdio 当 turn control plane(satisfied-as-is)**:跑卡内 grep 人工复核 docs/commands + tracing/code.md + tracing/agent.md + tracing/plan.md + src/command,全部命中要么正确描述为 MCP transport / tool surface(code.rs:40 "MCP tool surface only"、code.md(commands):18/212),要么显式否定该误读——code.md(commands):90「libra code --stdio remains the MCP stdio server and does not control a live TUI」、:330、code-control.md:8、tracing code.md:52/77、agent.md:551、plan.md:61/1480「MCP stdio 不得成为 turn control plane」;plan.md:23 提到的 memory.md `libra mcp --stdio` 冲突按 §0 out-of-scope 声明处理且本身不误述。**无 doc 把 MCP stdio 描述成 live-TUI/AgentRuntime turn control plane,criterion 3 无需修文档。** GAP-5(run_libra_vcs allowlist 文档漂移:code.md:291/293 少列 show-ref/ls-files 且反向禁止 ls-files)C6-relevant 但按卡归 C8,本卡不动。**(4) dual-entry tool set/error/shutdown 回归(GAP-FIX)**:原 code_mcp_dual_entry_test 只驱动 MCP **HTTP** 传输 + clap `--stdio`/`--web-only` 互斥;真正的 `--stdio` MCP 传输的 tool-set/error/shutdown 无回归。新增 2 个 test-provider-gated 用例:**libra_code_stdio_serves_tool_surface_reports_errors_and_shuts_down**——用子进程驱动真实 `libra code --stdio` 的换行分隔 JSON-RPC:tools/list 暴露共享工具面(断言含 run_libra_vcs/create_task/list_tasks/create_intent 且 ≥10 工具)、未知 method→-32601 顶层 error、未知 tool→invalid_params 顶层 error、stdin EOF→干净 exit 0(30s 看门狗把 shutdown 回归转成失败而非挂起);**mcp_http_and_stdio_expose_identical_tool_set**——断言 HTTP tools/list 集合 == stdio tools/list 集合(两入口共用 init_mcp_server→build_tool_router)。保留该文件「裸跑=1 skip 占位」契约(两测试同 gated on test-provider)。仅改 tests/code_mcp_dual_entry_test.rs(无 src 改动)。门禁:fmt/clippy --all-targets --all-features -D warnings 全净;code_mcp_dual_entry_test 14 ok(test-provider,含新 2 例);code_ui_remote_security_matrix 13 ok;code:: 113 单测全绿 | | 2026-07-06 | Task C8(Code docs, compatibility and final closeout) | 完成 | 0.18.20 | 本行所在提交 | Code 阶段最终收尾(docs/compat/index,无 src 改动)。**GAP-6..12**(7 项 flag/UI docs-drift,C7 前先行):docs/commands/code.md+zh-CN 补 --goal(goal 模式,parse 时校验非空+≤16KiB)/--agent(三层 profile lookup,binding 原子胜 --model,未知拒绝)/--approval-ttl(秒,覆盖项目 [approval] ttl_seconds,默认 300)/--kimi-stream(默认 true,非 Kimi 拒绝)行,allow-all approval 行+「four-tier→five-tier」,--plan-mode 默认澄清(off;Codex 有效默认 on,显式 =true 仅 codex),zh-CN browser-control 路由矩阵同步 EN+code_router()(补 /goal/status、/task/dispatch、/goal/start、/goal/cancel,删不存在的 /repo*)。**GAP-5**(run_libra_vcs allowlist doc,C7 确认未改 allowlist):code.md+zh-CN 从 8 命令+禁 ls-files 改为权威 10 命令(status/diff/branch/log/show/show-ref/ls-files/add/commit/switch,per libra_vcs.rs:11-13)并推荐 ls-files(含 --others --exclude-standard),与工具自身 guidance 一致。**COMPATIBILITY.md**:code/code-control 行已存在 intentionally-different/Libra-only,matrix_alignment 守卫过,无需改。**tests/INDEX.md**:15 个 Code 阶段 target 行核对,2 行(code_codex_default_tui_test/code_codex_runtime_test)源路径 stale 指向不存在的 agent/codex*→改为 src/internal/ai/codex/+准确用途,13 行准确无改,未动 agent 阶段行。**tracing code.md**:1 行(Mode 与参数)仍以现在时声称 web-only 漂移风险(C2 已解决)→更新为 C2 放宽+CLI 回归覆盖;其余核对无 stale。**CHANGELOG.md**:加 Code 阶段收尾条 + 「Mutating fix bridge deferred」条(review --fix/investigate fix 保持只读、LBR-AGENT-010,无 agent↔code mutating 协作边界,待 fix bridge 落地)。codex review 三轮:R1 FAIL 1P1+1P2(--approval-ttl 文档写 approval.ttl 实为 ttl_seconds;--plan-mode 文档暗示非 Codex 可 =true 实为拒绝)→修(EN+zh)→R2 FAIL 1P2(zh --plan-mode=false 写「会话退出」应为「关闭计划模式」)→修→R3 VERDICT: PASS 零发现。门禁:compat_matrix_alignment 7/compat_command_docs_examples_section 1/compat_help_examples_banner 1/compat_error_codes_doc_sync 2/fmt 全绿;全量 cargo test --all 终审门在本机受阻:并行 spawn-based command 测试(fsck/shortlog)在 ~test 101 处 deadlock,跨 C4-C8 复现、与负载无关、零 FAILED、单跑均通过——环境限制非代码缺陷;C8 纯 docs,代码即 C7 已验证 HEAD,逐 doc-guard(matrix_alignment 7/command_docs 1/help_examples 1/error_codes 2)+ codex R3 PASS + fmt 全绿。**并发事故处理**:merge origin/main(含 #431 reset PR)时 libra CLI panic 在共享 index 留下 #431 的伪 revert(cli.rs/reset.rs/cherry_pick.rs/launcher.rs/reset docs/COMPATIBILITY.md staged 删除),libra reset 卸暂存后 working tree 仍带该 revert→libra restore --source HEAD 恢复 11 个非 C8 文件到 HEAD(HEAD 正确含 #431),确认仅 5 个 C8 文件 dirty 后再提交。**Code 阶段 C1-C8 全部完成。** | + +## 11. 未完成功能项复核(2026-07-07) + +**复核时间**:2026-07-07(基线 HEAD 版本 `0.18.20`,§10 已把 Task 0.1–C8 全部标记「完成」之后的一次独立代码层复核)。 + +本节独立于 §10 进度表,记录在全部任务卡都已标记完成之后、通过对每张任务卡逐条验收标准做**代码层复核**(不以任务卡 `[x]` 或 §10「完成」行为准,遵循 §0 / §0.1.4 / §9「完成判定以代码为准」原则)发现的**未落地 / 契约漂移 / 文档-代码不一致**项。以下每项均以源码 `file:line` 为证据;`[x]` 与 §10 行是被复核的**声明**,不是完成证据。 + +**检查工具说明(每项标注由哪个工具查出)**: + +- **codex**:由 codex 独立静态审阅查出(UF-01 ~ UF-03)。本轮 Claude workflow 亦独立复现并对抗式验证(CONFIRMED),此三项归属 codex。 +- **Claude Code(多-agent 审计 workflow)**:本 session 对 A1–A9 / A8.5 / C1–C8 每张任务卡各派一名审计 agent + 一名对抗式验证 agent(`tracing-plan-completion-audit` workflow,51 agents,16 CONFIRMED / 15 REFUTED)查出并交叉验证(UF-04 ~ UF-11)。 + +**本轮复核覆盖说明**:C2 / C5 / C8 三卡零缺陷;`audit:A2`(AG-17 alias)与 `audit:A6.5`(本地三 agent smoke)两名审计 agent 中途 API stall 失败、**本轮未完成自动复核**(A6.5 本就依赖本机真实付费 agent、无法静态判定,需按 §0.3 现场跑)——这两卡的完整复核为**本节遗留项**,不代表已确认无缺口。 + +### 11.1 未完成功能项汇总表 + +| 编号 | 未完成项 | 关联卡 / 验收锚点 | 类别 | 严重度 | 检查工具 | 证据(file:line) | +|---|---|---|---|---|---|---| +| UF-01 | `agent.retention.stderr_days` 30 天 stderr/redaction-report blob 清理窗口未实现 | A8.5(plan.md:1044) | 功能缺口 | 高 | codex | `compliance.rs:79-87`(getter 零消费者);`clean.rs:87-115`(GC 仅用 `retention_transcript_days` 删整个 checkpoint);`agent.md:1866`(要求「删诊断 blob、保留聚合计数」的独立动作未实现) | +| UF-02 | `agent.retention.findings_days` review/investigate findings GC 未实现;deferred 前提失效;A9 release notes 未说明 | A8.5(plan.md:1045/1056)、A9(plan.md:1088) | 功能缺口 | 高 | codex | `compliance.rs:89-97`(getter 零消费者);`review.rs:160-168` / `investigate.rs` clean 仅 `--run/--all`;deferred 理由「A7/A8 未动工」已失效(A7=0.18.10、A8=0.18.12 均完成、A8 已建 `InvestigateRunStore` run-state);`CHANGELOG.md` 无该 deferral 说明 | +| UF-03 | codex `config_paths` 公共 JSON 契约漂移:`agent list --json` 暴露 `.codex/hooks.json`,Libra 实际不写该路径 | A1(plan.md:640)、A4、§0.3.3 | 契约漂移 | 中 | codex | `registry.rs:170`(`config_paths=[".codex/hooks.json"]`)vs 同行注释 `registry.rs:158-162` + 安装器 `codex/settings.rs:156-238`(实写用户级 `$CODEX_HOME/hooks.json`+`config.toml`,不写 project 路径,且字段漏 `config.toml`);`list.rs:100` 直出 JSON;pin 测试 `agent_capability_matrix_pin.rs:98` 钉死错误值;`agent.md:1711`「不得默认写未证实的 `.codex/hooks.json`」 | +| UF-04 | A9 发布说明 / `CHANGELOG` 缺整个「已启用 Agent 面」+ 迁移 + 错误码 + D1/R2 deferred 说明 | A9(plan.md:1081/1088、1046) | 功能缺口(发布门禁) | 高 | Claude Code(多-agent 审计 workflow) | `CHANGELOG.md` [Unreleased] 仅「Mutating fix bridge deferred」一条 agent 项;已启用的外部 agent 捕获(claude-code/codex/opencode)、agent trace/checkpoint、review/investigate workflow、`--allow-raw`+`agent_audit_log`、retention GC、external discovery opt-in、D1/R2 deletion-propagation deferred 均无 release note;新迁移 `2026070803_agent_audit_log`、错误码 `LBR-AGENT-013` 无 migration/release note(`agent.md:1378` 要求区分三类 deferral) | +| UF-05 | `SubagentStart`/`SubagentEnd` 的 `subagent_events` metadata 投影为死代码(已启用捕获路径不生效) | A4(plan.md:753 及 §10 A4 行)、`agent.md:277` | 功能缺口(死代码) | 中 | Claude Code(多-agent 审计 workflow) | 投影仅在 `lifecycle.rs:401-423`(经 AiIntent 入口 `runtime.rs:162` `process_hook_event_from_stdin`,全仓库零调用者);codex(唯一发该事件,`parser.rs:93-94`)两个 hook 入口均路由 AgentTraces(`hooks.rs:146`、`agent/hooks.rs:127`),其 metadata 由 `runtime.rs:814-842` 只投影 transcript_path/concurrent_active、checkpoint 仅 SessionEnd\|TurnEnd 写(`runtime.rs:751-754`);`subagent_events` 全仓库无读取者 | +| UF-06 | `libra code` 缺-key 错误消息打印**无法解析**的配置命令 | C3(criterion 2) | 功能缺口(错误消息不可用) | 中 | Claude Code(多-agent 审计 workflow) | `code.rs:1341-1344` 打印 `libra config --global add vault.env.{var} `,但 `config` 无 `add` 子命令(`add` 只是隐藏 `--add` bool,`config.rs:302-304`),该命令解析失败 exit 129;正确形态 `libra config set vault.env.{var} `(`internal/config.rs:1016` `resolve_required_env_sync` 及所有其它 provider 均用之);守卫测试 `code.rs:6059-6064` 只断言 `contains("vault.env")`,钉死坏命令 | +| UF-07 | `code.md` 文档的 Vault vs 进程环境优先级与实现相反 | C3(`code.md:49` Vault/env lookup 固定范围) | 文档-代码漂移 | 中 | Claude Code(多-agent 审计 workflow) | `docs/commands/code.md:80`(及 zh-CN:80)称「先 repo-local Vault → global Vault → 进程环境」,实现 `resolve_env_sync`(`internal/config.rs:988-1002`)先查进程环境再回落 vault(`config.rs:1042-1055` 注释自称进程环境为 per-process override、global 为 lowest priority);stale env + 新 vault 值场景下文档承诺 vault 胜、代码返回 stale env 值 | +| UF-08 | `agent.external_agents.{trusted_dirs,approved_binaries,env_allowlist_extra}` 为幽灵设置(文档列具体消费者,代码无一实现) | §0 issue #9、`agent.md:1232-1234` | 文档-代码漂移 | 中 | Claude Code(多-agent 审计 workflow) | `trusted_dirs` 标消费者 `libra agent rpc trust --dir`,但 `AgentRpcTrustArgs`(`command/agent/rpc.rs:60-63`)只有 slug 位参、无 `--dir`,发现走 $PATH 扫描;`approved_binaries` 真实存储是 `agent.trust.`(`trust.rs:27`);`env_allowlist_extra` 无消费者,spawn 硬编码 3 变量 allowlist(`observed_agents/rpc.rs:277-287`);仅 `agent.external_agents.enabled` 被消费 | +| UF-09 | `libra code --repo` flag 未进 `code.md` 权威 flags 表(EN+zh),C1 差距清单遗漏 | C1(plan.md:1172/1176)、C8 收尾 | 文档漂移 | 低 | Claude Code(多-agent 审计 workflow) | `code.rs:476-478` 定义非隐藏 flag `--repo`;`docs/commands/code.md:28-61` 与 zh-CN flags 表均无 `--repo` 行(仅示例 `code.md:241/274` 出现);C1 GAP-1..12 未列、C8 未补 | +| UF-10 | A5 验证清单陈旧计数:`agent_doctor_repair_test(8)` 实为 13 | A5(plan.md:828) | 文档-测试漂移(良性) | 低 | Claude Code(多-agent 审计 workflow) | `plan.md:828` 记 8;`tests/agent_doctor_repair_test.rs` 实有 13 用例(§10 A5 行 `plan.md:1593` 已记 13,验证清单行未同步);覆盖更多非缺口 | +| UF-11 | retention 默认值测试只钉常量、未演练 GC 消费者(stderr/findings 窗口零执行,造成覆盖假象) | A8.5(plan.md:1044「有测试」) | 测试漂移 | 低 | Claude Code(多-agent 审计 workflow) | `tests/agent_audit_log_test.rs:156-158` 仅断言三个 `DEFAULT_RETENTION_*_DAYS` 常量值,未调用 getter 或任何 GC 消费者;与 UF-01/UF-02 同源 | + +### 11.2 处置建议(供后续任务卡承接) + +- **UF-01 / UF-02 / UF-11(A8.5 retention GC 面)**:重开 A8.5 收尾切片——(a) 在 `clean --gc` 增加独立 `stderr_days`(默认 30)窗口,动作为「删除 stderr/redaction-report 诊断 blob、保留聚合计数」而非删整个 checkpoint;(b) 落地 `findings_days`(默认 90)对 `.libra/sessions/agent-runs//` run-state/findings 的 GC(并入 `clean --gc` 或 `review/investigate clean`——A7/A8 结构现已就绪,deferred 前提已消失);(c) 补真正演练两窗口的行为测试,替换纯常量 pin。 +- **UF-03(config_paths 契约)**:把 codex 行 `config_paths` 改为 Libra 实际托管面(用户级 `$CODEX_HOME/hooks.json` + `$CODEX_HOME/config.toml`,或按字段语义重定义并统一三 agent 口径),同步更新 `agent_capability_matrix_pin.rs:98`、`agent.md:226` 与 `agent list --json` 契约说明。 +- **UF-04(发布说明)**:在 `CHANGELOG.md` 补「已启用 Agent 捕获面」条目,并按 A9/`agent.md:1378` 显式区分 enabled / preview-opt-in(external discovery)/ explicitly-deferred(review/investigate `--fix`、D1/R2 deletion propagation、findings-GC),补 `2026070803_agent_audit_log` 迁移与 `LBR-AGENT-013` 错误码的 release/migration note。 +- **UF-05(subagent_events 死代码)**:要么让 AgentTraces ingest(`runtime.rs:814-842`)在 SubagentStart/End 上真正投影 capped `subagent_events`,要么删除死投影并把 §10/`agent.md:277` 的「已实现」表述降级为未落地。 +- **UF-06 / UF-07(`libra code` env 面)**:把缺-key 错误消息改为可解析的 `libra config set vault.env. `(并让守卫测试断言命令可解析);修正 `code.md:80`/zh-CN 的 Vault-vs-进程环境优先级描述与实现一致(进程环境优先)。 +- **UF-08(幽灵设置)**:删除 `agent.md:1232-1234` 三个无代码支撑的 settings 行,或补齐其消费者(`--dir` 参数、`approved_binaries` 键、`env_allowlist_extra` 透传)。 +- **UF-09 / UF-10(文档/测试小漂移)**:`code.md`+zh-CN flags 表补 `--repo` 行;`plan.md:828` 的 `(8)` 校正为 `(13)`。 + +**说明**:本节为复核记录,不修改 §10 既有「完成」行(§10 为 append-only 历史);后续按 §0.4/§0.5 承接上述项时,在 §10 追加对应发布行。A2 / A6.5 两卡的完整复核为本节遗留项,需在后续单独补做。 From 107474c58a79b9b27bb00b4ae7957db79842d8d2 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Tue, 7 Jul 2026 10:39:49 +0800 Subject: [PATCH 05/12] fix(status): honor ignorecase for case aliases Signed-off-by: Eli Ma --- src/command/add.rs | 6 +- src/command/status.rs | 69 ++++++++++++++++++---- src/command/status_untracked.rs | 3 +- src/command/status_untracked_paths.rs | 85 ++++++++++++++++++++------- 4 files changed, 128 insertions(+), 35 deletions(-) diff --git a/src/command/add.rs b/src/command/add.rs index 8dcc42e45..72debe10f 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -527,9 +527,11 @@ pub async fn run_add(args: &AddArgs) -> CliResult { }; let (mut visible_changes, mut ignored_changes) = if args.force { - status::changes_to_be_staged_split_force().map_err(|source| AddError::Status { source })? + status::changes_to_be_staged_split_force_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? } else { - status::changes_to_be_staged_split_safe().map_err(|source| AddError::Status { source })? + status::changes_to_be_staged_split_safe_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? }; if args.force { visible_changes.extend(ignored_changes.clone()); diff --git a/src/command/status.rs b/src/command/status.rs index 229ef6815..91041c009 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -393,6 +393,7 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult { .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("this command requires a working tree; bare repositories do not have one")); } + let ignore_case = effective_ignore_case_for_status().await?; let head = Head::current_result() .await @@ -406,9 +407,12 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult { .await .map(|c| c.to_relative()) .map_err(CliError::from)?; - let worktree = - status_untracked::collect_status_worktree_changes(args.untracked_files, args.ignored) - .map_err(CliError::from)?; + let worktree = status_untracked::collect_status_worktree_changes( + args.untracked_files, + args.ignored, + ignore_case, + ) + .map_err(CliError::from)?; let mut unstaged = status_untracked::changes_to_current_directory(worktree.unstaged); let ignored_files = worktree .ignored_files @@ -717,10 +721,19 @@ async fn compute_raw_sets() -> CliResult<(Changes, Changes)> { let staged = changes_to_be_committed_safe() .await .map_err(CliError::from)?; - let unstaged = changes_to_be_staged().map_err(CliError::from)?; + let ignore_case = effective_ignore_case_for_status().await?; + let unstaged = changes_to_be_staged_with_ignore_case(ignore_case).map_err(CliError::from)?; Ok((staged, unstaged)) } +async fn effective_ignore_case_for_status() -> CliResult { + crate::utils::path_case::effective_ignore_case() + .await + .map_err(|error| { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + }) +} + fn dirty_cache_error(action: &str, error: anyhow::Error) -> CliError { CliError::fatal(format!("failed to {action} the dirty cache: {error}")) .with_stable_code(StableErrorCode::IoWriteFailed) @@ -2561,13 +2574,29 @@ pub fn changes_to_be_staged() -> Result { /// Variant of [`changes_to_be_staged`] that lets callers pick the ignore strategy explicitly. /// Commands such as `add --force` or `status --ignored` can switch policies as needed. pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_with_policy_and_ignore_case(policy, ignore_case) +} + +pub(crate) fn changes_to_be_staged_with_ignore_case( + ignore_case: bool, +) -> Result { + changes_to_be_staged_with_policy_and_ignore_case(IgnorePolicy::Respect, ignore_case) +} + +fn changes_to_be_staged_with_policy_and_ignore_case( + policy: IgnorePolicy, + ignore_case: bool, +) -> Result { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - let (mut visible, ignored) = changes_to_be_staged_split_with_index(&workdir, &index)?; + let (mut visible, ignored) = + changes_to_be_staged_split_with_index(&workdir, &index, ignore_case)?; match policy { IgnorePolicy::Respect => Ok(visible), IgnorePolicy::OnlyIgnored => Ok(ignored), @@ -2579,34 +2608,51 @@ pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result Result<(Changes, Changes), StatusError> { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_split_safe_with_ignore_case(ignore_case) +} + +pub(crate) fn changes_to_be_staged_split_safe_with_ignore_case( + ignore_case: bool, +) -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - changes_to_be_staged_split_with_index(&workdir, &index) + changes_to_be_staged_split_with_index(&workdir, &index, ignore_case) } /// List changes to be staged with --force semantics (recurse into ignored directories) pub fn changes_to_be_staged_split_force() -> Result<(Changes, Changes), StatusError> { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_split_force_with_ignore_case(ignore_case) +} + +pub(crate) fn changes_to_be_staged_split_force_with_ignore_case( + ignore_case: bool, +) -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - changes_to_be_staged_split_force_with_index(&workdir, &index) + changes_to_be_staged_split_force_with_index(&workdir, &index, ignore_case) } fn changes_to_be_staged_split_force_with_index( workdir: &PathBuf, index: &Index, + ignore_case: bool, ) -> Result<(Changes, Changes), StatusError> { let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); - let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); + let tracked_fold = tracked_files_by_fold(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2655,11 +2701,12 @@ fn changes_to_be_staged_split_force_with_index( fn changes_to_be_staged_split_with_index( workdir: &PathBuf, index: &Index, + ignore_case: bool, ) -> Result<(Changes, Changes), StatusError> { let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); - let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); + let tracked_fold = tracked_files_by_fold(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2704,8 +2751,8 @@ fn changes_to_be_staged_split_with_index( Ok((visible, ignored)) } -fn tracked_files_by_fold(workdir: &Path, tracked_files: &[PathBuf]) -> HashMap { - if !crate::utils::path_case::probe_dir_ignore_case(workdir) { +fn tracked_files_by_fold(tracked_files: &[PathBuf], ignore_case: bool) -> HashMap { + if !ignore_case { return HashMap::new(); } tracked_files diff --git a/src/command/status_untracked.rs b/src/command/status_untracked.rs index e246c8cd7..5b57ce1dc 100644 --- a/src/command/status_untracked.rs +++ b/src/command/status_untracked.rs @@ -30,6 +30,7 @@ struct WorkdirScan { pub(crate) fn collect_status_worktree_changes( untracked_mode: UntrackedFiles, include_ignored: bool, + ignore_case: bool, ) -> Result { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; @@ -37,7 +38,7 @@ pub(crate) fn collect_status_worktree_changes( path: index_path.clone(), source, })?; - let tracked = TrackedPaths::from_index(&index); + let tracked = TrackedPaths::from_index(&index, ignore_case); let mut unstaged = collect_tracked_worktree_changes(&workdir, &index, tracked.files())?; let mut ignored_files = Vec::new(); diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index f218b13d9..f31fa2682 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -14,32 +14,37 @@ pub(crate) struct TrackedPaths { } impl TrackedPaths { - pub(crate) fn from_index(index: &Index) -> Self { + pub(crate) fn from_index(index: &Index, case_aliases_enabled: bool) -> Self { let files = index.tracked_files(); - let case_aliases_enabled = crate::utils::path_case::probe_workdir_ignore_case(); let top_level_dirs = files .iter() .filter_map(|path| top_level_dir(path)) .collect(); - let files_by_fold = files - .iter() - .map(|path| { - ( - crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), - path.clone(), - ) - }) - .collect(); - let top_level_dirs_by_fold = files - .iter() - .filter_map(|path| { - let dir = top_level_dir(path)?; - Some(( - crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), - dir, - )) - }) - .collect(); + let (files_by_fold, top_level_dirs_by_fold) = if case_aliases_enabled { + ( + files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect(), + files + .iter() + .filter_map(|path| { + let dir = top_level_dir(path)?; + Some(( + crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + dir, + )) + }) + .collect(), + ) + } else { + (HashMap::new(), HashMap::new()) + }; Self { files, case_aliases_enabled, @@ -153,3 +158,41 @@ fn top_level_dir(path: &Path) -> Option { let first = PathBuf::from(components.next()?.as_os_str()); components.next().map(|_| first) } + +#[cfg(test)] +mod tests { + use git_internal::{ + hash::ObjectHash, + internal::index::{Index, IndexEntry}, + }; + + use super::*; + + fn index_with_paths(paths: &[&str]) -> Index { + let mut index = Index::new(); + for path in paths { + index.add(IndexEntry::new_from_blob( + (*path).to_string(), + ObjectHash::new(&[1; 20]), + 0, + )); + } + index + } + + #[test] + fn tracked_paths_do_not_fold_when_case_aliases_are_disabled() { + let index = index_with_paths(&["slides/a.txt"]); + let tracked = TrackedPaths::from_index(&index, false); + + assert!(!tracked.has_descendant(Path::new("Slides"))); + } + + #[test] + fn tracked_paths_fold_descendants_when_case_aliases_are_enabled() { + let index = index_with_paths(&["slides/a.txt"]); + let tracked = TrackedPaths::from_index(&index, true); + + assert!(tracked.has_descendant(Path::new("Slides"))); + } +} From ccb8ae4c4322ba3ff00d6fa48af922eff4a2eb9e Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 7 Jul 2026 10:41:50 +0800 Subject: [PATCH 06/12] fix(status): honor ignorecase for case aliases Signed-off-by: Quanyi Ma --- src/command/add.rs | 6 +- src/command/status.rs | 69 ++++++++++++++++++---- src/command/status_untracked.rs | 3 +- src/command/status_untracked_paths.rs | 85 ++++++++++++++++++++------- 4 files changed, 128 insertions(+), 35 deletions(-) diff --git a/src/command/add.rs b/src/command/add.rs index 8dcc42e45..72debe10f 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -527,9 +527,11 @@ pub async fn run_add(args: &AddArgs) -> CliResult { }; let (mut visible_changes, mut ignored_changes) = if args.force { - status::changes_to_be_staged_split_force().map_err(|source| AddError::Status { source })? + status::changes_to_be_staged_split_force_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? } else { - status::changes_to_be_staged_split_safe().map_err(|source| AddError::Status { source })? + status::changes_to_be_staged_split_safe_with_ignore_case(ignore_case) + .map_err(|source| AddError::Status { source })? }; if args.force { visible_changes.extend(ignored_changes.clone()); diff --git a/src/command/status.rs b/src/command/status.rs index 229ef6815..91041c009 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -393,6 +393,7 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult { .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("this command requires a working tree; bare repositories do not have one")); } + let ignore_case = effective_ignore_case_for_status().await?; let head = Head::current_result() .await @@ -406,9 +407,12 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult { .await .map(|c| c.to_relative()) .map_err(CliError::from)?; - let worktree = - status_untracked::collect_status_worktree_changes(args.untracked_files, args.ignored) - .map_err(CliError::from)?; + let worktree = status_untracked::collect_status_worktree_changes( + args.untracked_files, + args.ignored, + ignore_case, + ) + .map_err(CliError::from)?; let mut unstaged = status_untracked::changes_to_current_directory(worktree.unstaged); let ignored_files = worktree .ignored_files @@ -717,10 +721,19 @@ async fn compute_raw_sets() -> CliResult<(Changes, Changes)> { let staged = changes_to_be_committed_safe() .await .map_err(CliError::from)?; - let unstaged = changes_to_be_staged().map_err(CliError::from)?; + let ignore_case = effective_ignore_case_for_status().await?; + let unstaged = changes_to_be_staged_with_ignore_case(ignore_case).map_err(CliError::from)?; Ok((staged, unstaged)) } +async fn effective_ignore_case_for_status() -> CliResult { + crate::utils::path_case::effective_ignore_case() + .await + .map_err(|error| { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + }) +} + fn dirty_cache_error(action: &str, error: anyhow::Error) -> CliError { CliError::fatal(format!("failed to {action} the dirty cache: {error}")) .with_stable_code(StableErrorCode::IoWriteFailed) @@ -2561,13 +2574,29 @@ pub fn changes_to_be_staged() -> Result { /// Variant of [`changes_to_be_staged`] that lets callers pick the ignore strategy explicitly. /// Commands such as `add --force` or `status --ignored` can switch policies as needed. pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_with_policy_and_ignore_case(policy, ignore_case) +} + +pub(crate) fn changes_to_be_staged_with_ignore_case( + ignore_case: bool, +) -> Result { + changes_to_be_staged_with_policy_and_ignore_case(IgnorePolicy::Respect, ignore_case) +} + +fn changes_to_be_staged_with_policy_and_ignore_case( + policy: IgnorePolicy, + ignore_case: bool, +) -> Result { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - let (mut visible, ignored) = changes_to_be_staged_split_with_index(&workdir, &index)?; + let (mut visible, ignored) = + changes_to_be_staged_split_with_index(&workdir, &index, ignore_case)?; match policy { IgnorePolicy::Respect => Ok(visible), IgnorePolicy::OnlyIgnored => Ok(ignored), @@ -2579,34 +2608,51 @@ pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result Result<(Changes, Changes), StatusError> { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_split_safe_with_ignore_case(ignore_case) +} + +pub(crate) fn changes_to_be_staged_split_safe_with_ignore_case( + ignore_case: bool, +) -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - changes_to_be_staged_split_with_index(&workdir, &index) + changes_to_be_staged_split_with_index(&workdir, &index, ignore_case) } /// List changes to be staged with --force semantics (recurse into ignored directories) pub fn changes_to_be_staged_split_force() -> Result<(Changes, Changes), StatusError> { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + changes_to_be_staged_split_force_with_ignore_case(ignore_case) +} + +pub(crate) fn changes_to_be_staged_split_force_with_ignore_case( + ignore_case: bool, +) -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; let index = Index::load(&index_path).map_err(|source| StatusError::IndexLoad { path: index_path.clone(), source, })?; - changes_to_be_staged_split_force_with_index(&workdir, &index) + changes_to_be_staged_split_force_with_index(&workdir, &index, ignore_case) } fn changes_to_be_staged_split_force_with_index( workdir: &PathBuf, index: &Index, + ignore_case: bool, ) -> Result<(Changes, Changes), StatusError> { let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); - let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); + let tracked_fold = tracked_files_by_fold(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2655,11 +2701,12 @@ fn changes_to_be_staged_split_force_with_index( fn changes_to_be_staged_split_with_index( workdir: &PathBuf, index: &Index, + ignore_case: bool, ) -> Result<(Changes, Changes), StatusError> { let mut visible = Changes::default(); let mut ignored = Changes::default(); let tracked_files = index.tracked_files(); - let tracked_fold = tracked_files_by_fold(workdir, &tracked_files); + let tracked_fold = tracked_files_by_fold(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2704,8 +2751,8 @@ fn changes_to_be_staged_split_with_index( Ok((visible, ignored)) } -fn tracked_files_by_fold(workdir: &Path, tracked_files: &[PathBuf]) -> HashMap { - if !crate::utils::path_case::probe_dir_ignore_case(workdir) { +fn tracked_files_by_fold(tracked_files: &[PathBuf], ignore_case: bool) -> HashMap { + if !ignore_case { return HashMap::new(); } tracked_files diff --git a/src/command/status_untracked.rs b/src/command/status_untracked.rs index e246c8cd7..5b57ce1dc 100644 --- a/src/command/status_untracked.rs +++ b/src/command/status_untracked.rs @@ -30,6 +30,7 @@ struct WorkdirScan { pub(crate) fn collect_status_worktree_changes( untracked_mode: UntrackedFiles, include_ignored: bool, + ignore_case: bool, ) -> Result { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; let index_path = path::try_index().map_err(|source| StatusError::Workdir { source })?; @@ -37,7 +38,7 @@ pub(crate) fn collect_status_worktree_changes( path: index_path.clone(), source, })?; - let tracked = TrackedPaths::from_index(&index); + let tracked = TrackedPaths::from_index(&index, ignore_case); let mut unstaged = collect_tracked_worktree_changes(&workdir, &index, tracked.files())?; let mut ignored_files = Vec::new(); diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index f218b13d9..f31fa2682 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -14,32 +14,37 @@ pub(crate) struct TrackedPaths { } impl TrackedPaths { - pub(crate) fn from_index(index: &Index) -> Self { + pub(crate) fn from_index(index: &Index, case_aliases_enabled: bool) -> Self { let files = index.tracked_files(); - let case_aliases_enabled = crate::utils::path_case::probe_workdir_ignore_case(); let top_level_dirs = files .iter() .filter_map(|path| top_level_dir(path)) .collect(); - let files_by_fold = files - .iter() - .map(|path| { - ( - crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), - path.clone(), - ) - }) - .collect(); - let top_level_dirs_by_fold = files - .iter() - .filter_map(|path| { - let dir = top_level_dir(path)?; - Some(( - crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), - dir, - )) - }) - .collect(); + let (files_by_fold, top_level_dirs_by_fold) = if case_aliases_enabled { + ( + files + .iter() + .map(|path| { + ( + crate::utils::path_case::fold_path_key(path.to_string_lossy().as_ref()), + path.clone(), + ) + }) + .collect(), + files + .iter() + .filter_map(|path| { + let dir = top_level_dir(path)?; + Some(( + crate::utils::path_case::fold_path_key(dir.to_string_lossy().as_ref()), + dir, + )) + }) + .collect(), + ) + } else { + (HashMap::new(), HashMap::new()) + }; Self { files, case_aliases_enabled, @@ -153,3 +158,41 @@ fn top_level_dir(path: &Path) -> Option { let first = PathBuf::from(components.next()?.as_os_str()); components.next().map(|_| first) } + +#[cfg(test)] +mod tests { + use git_internal::{ + hash::ObjectHash, + internal::index::{Index, IndexEntry}, + }; + + use super::*; + + fn index_with_paths(paths: &[&str]) -> Index { + let mut index = Index::new(); + for path in paths { + index.add(IndexEntry::new_from_blob( + (*path).to_string(), + ObjectHash::new(&[1; 20]), + 0, + )); + } + index + } + + #[test] + fn tracked_paths_do_not_fold_when_case_aliases_are_disabled() { + let index = index_with_paths(&["slides/a.txt"]); + let tracked = TrackedPaths::from_index(&index, false); + + assert!(!tracked.has_descendant(Path::new("Slides"))); + } + + #[test] + fn tracked_paths_fold_descendants_when_case_aliases_are_enabled() { + let index = index_with_paths(&["slides/a.txt"]); + let tracked = TrackedPaths::from_index(&index, true); + + assert!(tracked.has_descendant(Path::new("Slides"))); + } +} From 72a477072742c4d8baf4b4d1fb245deeac46556f Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Tue, 7 Jul 2026 10:45:42 +0800 Subject: [PATCH 07/12] docs(pr): add long-term GitHub PR creation solution design Signed-off-by: Eli Ma --- docs/development/experience/pr.md | 397 ++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/development/experience/pr.md diff --git a/docs/development/experience/pr.md b/docs/development/experience/pr.md new file mode 100644 index 000000000..418f85ffa --- /dev/null +++ b/docs/development/experience/pr.md @@ -0,0 +1,397 @@ +# Libra PR 长期解决方案:基于 `gh` 的 GitHub PR 命令 + +## 背景与问题 + +Libra 当前的 Git-compatible 命令族(`branch` / `switch` / `commit` / `push` / `open`)已经能完成 +"准备一个可被 review 的分支" 的全部工作,但 **无法直接创建 Pull Request**。原因是 PR 不是 Git +协议的一部分,而是 GitHub / GitLab / Gitea 等托管平台的 API 概念,Libra 目前没有托管平台层。 + +当前可行的临时流程是: + +```bash +libra switch -c feature/my-change +libra add . +libra commit -s -m "feat(scope): describe change" +libra push -u origin feature/my-change +libra open https://github.com///compare/main...feature/my-change?expand=1 +``` + +最后一步仍需在浏览器里手动点 "Create pull request",不是一条命令的体验。 + +本方案给出一个 **长期可维护** 的实现路径:在 Libra 内新增 `libra pr` 命令族,以本地 `gh` +(GitHub CLI)作为 GitHub PR 后端执行器。 + +## 设计原则 + +### 为什么用 `gh` 作为后端,而不是直接实现 GitHub API + +优点: + +- 避免 Libra 自己维护 GitHub token 存储、OAuth / device flow、Enterprise host、2FA、SSO 等认证复杂度。 +- `gh` 已经覆盖 GitHub.com 和 GitHub Enterprise。 +- `gh pr create` 语义成熟,支持 `--fill` / `--draft` / `--reviewer` / `--label` / `--assignee` / + `--project` / `--web` 等。 +- Libra 先提供稳定的 PR UX,未来如需可替换为原生 GitHub API provider。 +- 失败时保留 `gh` 原始错误信息,同时在 Libra 层补充上下文和修复建议。 + +缺点: + +- 多一个外部运行时依赖。 +- 行为受用户本机 `gh` 版本影响。 +- JSON / 机器可读输出需要约束 `gh` 调用方式,不能完全依赖 human output。 + +长期看,这比在 Libra 内直接重写 GitHub 客户端更低风险、更容易维护。 + +### 职责边界 + +- **Libra 负责**:VCS 上下文、分支状态、diff、push、安全校验、统一 UX 与稳定 JSON schema。 +- **`gh` 负责**:GitHub 认证、host 配置、PR 创建、浏览器打开、Enterprise 兼容。 + +关键约束:**push 必须用 `libra push`,不要让 `gh` 或 `git` 替 Libra 更新 ref**。Libra 是 VCS 主体, +`gh` 只负责 GitHub PR API 这一步。 + +## 命令设计 + +新增命令族(第一阶段只实现 `create`): + +```bash +libra pr create [OPTIONS] +libra pr status [OPTIONS] # 后续阶段 +libra pr view [] [OPTIONS] # 后续阶段 +libra pr checkout [OPTIONS] # 后续阶段 +``` + +### `libra pr create` 参数 + +```bash +libra pr create \ + [--base ] \ + [--head ] \ + [--title ] \ + [--body <body>] \ + [--body-file <path>] \ + [--draft] \ + [--fill] \ + [--web] \ + [--push] \ + [--dry-run] \ + [--json] +``` + +### 默认行为推断 + +- `--head`:缺省取当前分支。 +- 目标 remote:当前分支 upstream 的 remote,找不到则用 `origin`。 +- `--base`:优先级为 显式 `--base` → remote 默认分支 → `main` / `master` 探测。 +- 当前分支未 push 时 **默认报错** 并提示 `--push`,不静默 push。 +- 工作区 dirty 时默认允许创建 PR(PR 关注的是已 push 的 commit,不是工作区),但给出提示; + 可选 `--require-clean` 拒绝。 +- 当前分支相对 base 没有 ahead commit 时直接拒绝。 +- 成功后输出 PR URL;`--web` 走 `gh pr create --web`。 +- `--json` 输出 Libra 自己的 envelope,不直接透传 `gh` human output。 + +## 内部流程 + +`libra pr create` 按以下顺序执行: + +1. 检查当前目录是 Libra repo。 +2. 检查 `gh` 是否存在(`gh --version`)。 +3. 检查当前分支不是 detached HEAD。 +4. 解析 remote URL,确认是 GitHub remote(拒绝非 GitHub remote)。 +5. 检查 `gh auth status --hostname <host>`。 +6. 计算当前分支相对 base 是否有 ahead commit。 +7. 检查远端是否已有 head 分支(`refs/remotes/<remote>/<head>` 是否存在且与本地一致)。 +8. 若无远端分支: + - 无 `--push`:报错并提示 `libra push -u origin <branch>` 或 `libra pr create --push`。 + - 有 `--push`:执行 `libra push -u origin <branch>`,而不是调用 `gh` push。 +9. 组装 `gh pr create` 参数。 +10. 执行 `gh pr create`(不通过 shell,见下节)。 +11. 解析 PR URL。 +12. 输出人类可读结果或 JSON。 + +## 参数映射 + +Libra 参数到 `gh` 参数: + +| Libra 选项 | `gh` 参数 | +|------------|-----------| +| `--base main` | `--base main` | +| `--head feature/x` | `--head feature/x` | +| `--title "..."` | `--title "..."` | +| `--body "..."` | `--body "..."` | +| `--body-file PR.md` | `--body-file PR.md` | +| `--draft` | `--draft` | +| `--fill` | `--fill` | +| `--web` | `--web` | + +后续可扩展的 GitHub 元数据(直接透传到 `gh`): + +```bash +--reviewer <login> +--assignee <login> +--label <name> +--milestone <name> +--project <name> +``` + +## 安全边界 + +因为要执行外部命令,**必须避免 shell 拼接**。使用 `std::process::Command` 逐个传 argv: + +```rust +Command::new("gh") + .arg("pr") + .arg("create") + .arg("--base") + .arg(base) + .arg("--head") + .arg(head) +``` + +不要构造字符串再交给 shell: + +```rust +// 禁止:sh -c "gh pr create --title ..." +``` + +其他注意点: + +- `--body-file` 必须走路径规范化,错误要带上下文。 +- remote URL 解析不能允许 `file://`、`ssh://evil` 等被误判成 GitHub。 +- `--json` 模式下不要打开浏览器。 +- 不要打印 token、`gh auth status` 的敏感信息。 +- `--dry-run` 不得调用真正的 `gh pr create`,只打印将执行的动作。 + +## 错误处理 + +把常见失败转成 Libra 风格的可行动错误: + +```text +error: GitHub CLI is not installed +hint: install it from https://cli.github.com/ or use `brew install gh` +``` + +```text +error: GitHub CLI is not authenticated for github.com +hint: run `gh auth login --hostname github.com` +``` + +```text +error: current branch 'feature/x' has not been pushed to origin +hint: run `libra push -u origin feature/x` or retry with `libra pr create --push` +``` + +```text +error: remote 'origin' is not a GitHub remote +hint: `libra pr create` currently supports only GitHub remotes through gh +``` + +```text +error: no commits to propose +hint: commit changes first, or choose a different base with `--base` +``` + +## JSON 输出 + +Libra 自己定义稳定 schema,不透传 `gh` 的输出: + +```json +{ + "ok": true, + "command": "pr create", + "data": { + "provider": "github", + "backend": "gh", + "remote": "origin", + "repository": "owner/repo", + "base": "main", + "head": "feature/my-change", + "url": "https://github.com/owner/repo/pull/123", + "pushed": true, + "draft": false + } +} +``` + +`--dry-run --json`: + +```json +{ + "ok": true, + "command": "pr create", + "data": { + "dry_run": true, + "provider": "github", + "backend": "gh", + "remote": "origin", + "repository": "owner/repo", + "base": "main", + "head": "feature/my-change", + "would_push": false, + "gh_args": [ + "pr", "create", + "--repo", "owner/repo", + "--base", "main", + "--head", "feature/my-change", + "--fill" + ] + } +} +``` + +`gh_args` 是否暴露可讨论;若暴露,要保证不含 secret。 + +## 长期架构 + +不要把 `gh` 调用直接塞进 `src/command/pr.rs` 的大函数里,建议分层: + +```text +src/command/pr.rs CLI 参数、输出格式、错误展示 +src/internal/github/mod.rs GitHub remote 识别、owner/repo/host 解析 +src/internal/github/gh.rs gh 可用性检查、auth status、pr create 调用 +src/internal/pr.rs PR 创建前置校验、base/head 推断、调用 provider +``` + +可抽象一个轻量 trait,但第一版不必过度设计: + +```rust +trait PullRequestProvider { + async fn create(&self, request: CreatePullRequestRequest) -> Result<CreatePullRequestResponse>; +} +``` + +当前只实现 `GhGitHubProvider`。未来如需原生 API 或其他平台,可新增 +`NativeGitHubProvider` / `GitLabProvider` / `GiteaProvider`。第一版可以先不引入 trait, +除非测试需要 mock。 + +## 推荐默认 UX + +最理想的一条命令体验: + +```bash +libra pr create --fill --push +``` + +行为: + +1. 检查当前分支。 +2. 自动推送当前分支到 `origin` 并设置 upstream。 +3. 调用 `gh pr create --fill`。 +4. 输出 PR URL。 + +典型输出: + +```text +Pushed feature/my-change to origin. +Created pull request: +https://github.com/owner/repo/pull/123 +``` + +若不想自动 push: + +```bash +libra pr create --fill +``` + +当前分支未 push 时: + +```text +error: current branch 'feature/my-change' has not been pushed to origin +hint: run `libra push -u origin feature/my-change` or retry with `libra pr create --push --fill` +``` + +## 测试策略 + +### 单元测试 + +- GitHub remote URL 解析: + - `git@github.com:owner/repo.git` + - `https://github.com/owner/repo.git` + - `ssh://git@github.com/owner/repo.git` + - GitHub Enterprise host + - 非 GitHub remote 拒绝 +- base / head 推断。 +- `gh` argv 组装(不使用 shell)。 +- `--body` / `--body-file` / `--fill` 冲突规则。 +- `--dry-run` 不执行外部创建。 + +### 集成测试 + +- fake `gh` binary 放到临时 `PATH`,记录 argv。 +- fake `gh` 返回成功 URL,验证 Libra JSON / human 输出。 +- fake `gh` 返回 auth 失败,验证错误和 hint。 +- fake `gh` 返回非 0,验证 Libra 不吞错误。 +- `--push` 时验证调用的是 Libra push 逻辑,或在测试中 mock push 层。 + +不建议默认 CI 依赖真实 GitHub 网络。真实 `gh` + GitHub 的测试放 feature-gated / live test: + +```bash +cargo test --features test-live-github --test pr_github_live_test +``` + +## 文档更新 + +落地时需同步: + +- 新增 `docs/commands/pr.md` 与 `docs/commands/zh-CN/pr.md`。 +- `COMPATIBILITY.md`:标记 `pr` 为 Libra GitHub extension,非 Git 命令。 +- `docs/error-codes.md`:新增外部工具缺失、认证失败、remote 非 GitHub、未 push 等错误码。 +- README command list 更新。 +- `tests/INDEX.md`(若新增 integration target)。 + +## 分阶段落地 + +### 第 1 阶段:只读准备能力 + +```bash +libra pr create --dry-run +``` + +完成 remote 解析、base / head 推断、gh 检查、argv 生成,不真正创建 PR。 + +### 第 2 阶段:最小可创建 + +```bash +libra pr create --base main --title "..." --body "..." +libra pr create --fill +``` + +要求分支已 push,不自动 push。 + +### 第 3 阶段:自动 push + +```bash +libra pr create --push --fill +``` + +用 `libra push -u origin <branch>` 推送,成功后调用 `gh`。 + +### 第 4 阶段:完整 GitHub UX + +```bash +libra pr create --draft --reviewer alice --label bug --web +libra pr status +libra pr view +libra pr checkout +``` + +## 结论 + +长期最优解不是让用户手动组合 `libra push` + `gh pr create`,而是在 Libra 里提供稳定的 +`libra pr create` 门面: + +```bash +libra pr create --push --fill +``` + +内部规则: + +- Libra 负责仓库状态和 push。 +- `gh` 负责 GitHub PR 创建。 +- 不通过 shell 调用 `gh`。 +- 默认不静默 push,必须显式 `--push`。 +- 输出 Libra 自己的稳定 JSON schema。 +- 第一版只支持 GitHub,非 GitHub remote 明确拒绝。 + +这能在很低实现成本下获得长期可维护的 GitHub PR 能力,同时不把 Libra 绑定到一套自维护的 +GitHub API / auth 实现。 From 31c17314e1ee7e3d28d079dde7db9007e805ef3d Mon Sep 17 00:00:00 2001 From: Quanyi Ma <eli@patch.sh> Date: Tue, 7 Jul 2026 10:56:23 +0800 Subject: [PATCH 08/12] docs: add PR plan and tracing audit Signed-off-by: Quanyi Ma <eli@patch.sh> --- docs/development/experience/pr.md | 397 ++++++++++++++++++++++++++++++ docs/development/tracing/plan.md | 42 +++- 2 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 docs/development/experience/pr.md diff --git a/docs/development/experience/pr.md b/docs/development/experience/pr.md new file mode 100644 index 000000000..418f85ffa --- /dev/null +++ b/docs/development/experience/pr.md @@ -0,0 +1,397 @@ +# Libra PR 长期解决方案:基于 `gh` 的 GitHub PR 命令 + +## 背景与问题 + +Libra 当前的 Git-compatible 命令族(`branch` / `switch` / `commit` / `push` / `open`)已经能完成 +"准备一个可被 review 的分支" 的全部工作,但 **无法直接创建 Pull Request**。原因是 PR 不是 Git +协议的一部分,而是 GitHub / GitLab / Gitea 等托管平台的 API 概念,Libra 目前没有托管平台层。 + +当前可行的临时流程是: + +```bash +libra switch -c feature/my-change +libra add . +libra commit -s -m "feat(scope): describe change" +libra push -u origin feature/my-change +libra open https://github.com/<owner>/<repo>/compare/main...feature/my-change?expand=1 +``` + +最后一步仍需在浏览器里手动点 "Create pull request",不是一条命令的体验。 + +本方案给出一个 **长期可维护** 的实现路径:在 Libra 内新增 `libra pr` 命令族,以本地 `gh` +(GitHub CLI)作为 GitHub PR 后端执行器。 + +## 设计原则 + +### 为什么用 `gh` 作为后端,而不是直接实现 GitHub API + +优点: + +- 避免 Libra 自己维护 GitHub token 存储、OAuth / device flow、Enterprise host、2FA、SSO 等认证复杂度。 +- `gh` 已经覆盖 GitHub.com 和 GitHub Enterprise。 +- `gh pr create` 语义成熟,支持 `--fill` / `--draft` / `--reviewer` / `--label` / `--assignee` / + `--project` / `--web` 等。 +- Libra 先提供稳定的 PR UX,未来如需可替换为原生 GitHub API provider。 +- 失败时保留 `gh` 原始错误信息,同时在 Libra 层补充上下文和修复建议。 + +缺点: + +- 多一个外部运行时依赖。 +- 行为受用户本机 `gh` 版本影响。 +- JSON / 机器可读输出需要约束 `gh` 调用方式,不能完全依赖 human output。 + +长期看,这比在 Libra 内直接重写 GitHub 客户端更低风险、更容易维护。 + +### 职责边界 + +- **Libra 负责**:VCS 上下文、分支状态、diff、push、安全校验、统一 UX 与稳定 JSON schema。 +- **`gh` 负责**:GitHub 认证、host 配置、PR 创建、浏览器打开、Enterprise 兼容。 + +关键约束:**push 必须用 `libra push`,不要让 `gh` 或 `git` 替 Libra 更新 ref**。Libra 是 VCS 主体, +`gh` 只负责 GitHub PR API 这一步。 + +## 命令设计 + +新增命令族(第一阶段只实现 `create`): + +```bash +libra pr create [OPTIONS] +libra pr status [OPTIONS] # 后续阶段 +libra pr view [<number>] [OPTIONS] # 后续阶段 +libra pr checkout <number> [OPTIONS] # 后续阶段 +``` + +### `libra pr create` 参数 + +```bash +libra pr create \ + [--base <branch>] \ + [--head <branch>] \ + [--title <title>] \ + [--body <body>] \ + [--body-file <path>] \ + [--draft] \ + [--fill] \ + [--web] \ + [--push] \ + [--dry-run] \ + [--json] +``` + +### 默认行为推断 + +- `--head`:缺省取当前分支。 +- 目标 remote:当前分支 upstream 的 remote,找不到则用 `origin`。 +- `--base`:优先级为 显式 `--base` → remote 默认分支 → `main` / `master` 探测。 +- 当前分支未 push 时 **默认报错** 并提示 `--push`,不静默 push。 +- 工作区 dirty 时默认允许创建 PR(PR 关注的是已 push 的 commit,不是工作区),但给出提示; + 可选 `--require-clean` 拒绝。 +- 当前分支相对 base 没有 ahead commit 时直接拒绝。 +- 成功后输出 PR URL;`--web` 走 `gh pr create --web`。 +- `--json` 输出 Libra 自己的 envelope,不直接透传 `gh` human output。 + +## 内部流程 + +`libra pr create` 按以下顺序执行: + +1. 检查当前目录是 Libra repo。 +2. 检查 `gh` 是否存在(`gh --version`)。 +3. 检查当前分支不是 detached HEAD。 +4. 解析 remote URL,确认是 GitHub remote(拒绝非 GitHub remote)。 +5. 检查 `gh auth status --hostname <host>`。 +6. 计算当前分支相对 base 是否有 ahead commit。 +7. 检查远端是否已有 head 分支(`refs/remotes/<remote>/<head>` 是否存在且与本地一致)。 +8. 若无远端分支: + - 无 `--push`:报错并提示 `libra push -u origin <branch>` 或 `libra pr create --push`。 + - 有 `--push`:执行 `libra push -u origin <branch>`,而不是调用 `gh` push。 +9. 组装 `gh pr create` 参数。 +10. 执行 `gh pr create`(不通过 shell,见下节)。 +11. 解析 PR URL。 +12. 输出人类可读结果或 JSON。 + +## 参数映射 + +Libra 参数到 `gh` 参数: + +| Libra 选项 | `gh` 参数 | +|------------|-----------| +| `--base main` | `--base main` | +| `--head feature/x` | `--head feature/x` | +| `--title "..."` | `--title "..."` | +| `--body "..."` | `--body "..."` | +| `--body-file PR.md` | `--body-file PR.md` | +| `--draft` | `--draft` | +| `--fill` | `--fill` | +| `--web` | `--web` | + +后续可扩展的 GitHub 元数据(直接透传到 `gh`): + +```bash +--reviewer <login> +--assignee <login> +--label <name> +--milestone <name> +--project <name> +``` + +## 安全边界 + +因为要执行外部命令,**必须避免 shell 拼接**。使用 `std::process::Command` 逐个传 argv: + +```rust +Command::new("gh") + .arg("pr") + .arg("create") + .arg("--base") + .arg(base) + .arg("--head") + .arg(head) +``` + +不要构造字符串再交给 shell: + +```rust +// 禁止:sh -c "gh pr create --title ..." +``` + +其他注意点: + +- `--body-file` 必须走路径规范化,错误要带上下文。 +- remote URL 解析不能允许 `file://`、`ssh://evil` 等被误判成 GitHub。 +- `--json` 模式下不要打开浏览器。 +- 不要打印 token、`gh auth status` 的敏感信息。 +- `--dry-run` 不得调用真正的 `gh pr create`,只打印将执行的动作。 + +## 错误处理 + +把常见失败转成 Libra 风格的可行动错误: + +```text +error: GitHub CLI is not installed +hint: install it from https://cli.github.com/ or use `brew install gh` +``` + +```text +error: GitHub CLI is not authenticated for github.com +hint: run `gh auth login --hostname github.com` +``` + +```text +error: current branch 'feature/x' has not been pushed to origin +hint: run `libra push -u origin feature/x` or retry with `libra pr create --push` +``` + +```text +error: remote 'origin' is not a GitHub remote +hint: `libra pr create` currently supports only GitHub remotes through gh +``` + +```text +error: no commits to propose +hint: commit changes first, or choose a different base with `--base` +``` + +## JSON 输出 + +Libra 自己定义稳定 schema,不透传 `gh` 的输出: + +```json +{ + "ok": true, + "command": "pr create", + "data": { + "provider": "github", + "backend": "gh", + "remote": "origin", + "repository": "owner/repo", + "base": "main", + "head": "feature/my-change", + "url": "https://github.com/owner/repo/pull/123", + "pushed": true, + "draft": false + } +} +``` + +`--dry-run --json`: + +```json +{ + "ok": true, + "command": "pr create", + "data": { + "dry_run": true, + "provider": "github", + "backend": "gh", + "remote": "origin", + "repository": "owner/repo", + "base": "main", + "head": "feature/my-change", + "would_push": false, + "gh_args": [ + "pr", "create", + "--repo", "owner/repo", + "--base", "main", + "--head", "feature/my-change", + "--fill" + ] + } +} +``` + +`gh_args` 是否暴露可讨论;若暴露,要保证不含 secret。 + +## 长期架构 + +不要把 `gh` 调用直接塞进 `src/command/pr.rs` 的大函数里,建议分层: + +```text +src/command/pr.rs CLI 参数、输出格式、错误展示 +src/internal/github/mod.rs GitHub remote 识别、owner/repo/host 解析 +src/internal/github/gh.rs gh 可用性检查、auth status、pr create 调用 +src/internal/pr.rs PR 创建前置校验、base/head 推断、调用 provider +``` + +可抽象一个轻量 trait,但第一版不必过度设计: + +```rust +trait PullRequestProvider { + async fn create(&self, request: CreatePullRequestRequest) -> Result<CreatePullRequestResponse>; +} +``` + +当前只实现 `GhGitHubProvider`。未来如需原生 API 或其他平台,可新增 +`NativeGitHubProvider` / `GitLabProvider` / `GiteaProvider`。第一版可以先不引入 trait, +除非测试需要 mock。 + +## 推荐默认 UX + +最理想的一条命令体验: + +```bash +libra pr create --fill --push +``` + +行为: + +1. 检查当前分支。 +2. 自动推送当前分支到 `origin` 并设置 upstream。 +3. 调用 `gh pr create --fill`。 +4. 输出 PR URL。 + +典型输出: + +```text +Pushed feature/my-change to origin. +Created pull request: +https://github.com/owner/repo/pull/123 +``` + +若不想自动 push: + +```bash +libra pr create --fill +``` + +当前分支未 push 时: + +```text +error: current branch 'feature/my-change' has not been pushed to origin +hint: run `libra push -u origin feature/my-change` or retry with `libra pr create --push --fill` +``` + +## 测试策略 + +### 单元测试 + +- GitHub remote URL 解析: + - `git@github.com:owner/repo.git` + - `https://github.com/owner/repo.git` + - `ssh://git@github.com/owner/repo.git` + - GitHub Enterprise host + - 非 GitHub remote 拒绝 +- base / head 推断。 +- `gh` argv 组装(不使用 shell)。 +- `--body` / `--body-file` / `--fill` 冲突规则。 +- `--dry-run` 不执行外部创建。 + +### 集成测试 + +- fake `gh` binary 放到临时 `PATH`,记录 argv。 +- fake `gh` 返回成功 URL,验证 Libra JSON / human 输出。 +- fake `gh` 返回 auth 失败,验证错误和 hint。 +- fake `gh` 返回非 0,验证 Libra 不吞错误。 +- `--push` 时验证调用的是 Libra push 逻辑,或在测试中 mock push 层。 + +不建议默认 CI 依赖真实 GitHub 网络。真实 `gh` + GitHub 的测试放 feature-gated / live test: + +```bash +cargo test --features test-live-github --test pr_github_live_test +``` + +## 文档更新 + +落地时需同步: + +- 新增 `docs/commands/pr.md` 与 `docs/commands/zh-CN/pr.md`。 +- `COMPATIBILITY.md`:标记 `pr` 为 Libra GitHub extension,非 Git 命令。 +- `docs/error-codes.md`:新增外部工具缺失、认证失败、remote 非 GitHub、未 push 等错误码。 +- README command list 更新。 +- `tests/INDEX.md`(若新增 integration target)。 + +## 分阶段落地 + +### 第 1 阶段:只读准备能力 + +```bash +libra pr create --dry-run +``` + +完成 remote 解析、base / head 推断、gh 检查、argv 生成,不真正创建 PR。 + +### 第 2 阶段:最小可创建 + +```bash +libra pr create --base main --title "..." --body "..." +libra pr create --fill +``` + +要求分支已 push,不自动 push。 + +### 第 3 阶段:自动 push + +```bash +libra pr create --push --fill +``` + +用 `libra push -u origin <branch>` 推送,成功后调用 `gh`。 + +### 第 4 阶段:完整 GitHub UX + +```bash +libra pr create --draft --reviewer alice --label bug --web +libra pr status +libra pr view +libra pr checkout +``` + +## 结论 + +长期最优解不是让用户手动组合 `libra push` + `gh pr create`,而是在 Libra 里提供稳定的 +`libra pr create` 门面: + +```bash +libra pr create --push --fill +``` + +内部规则: + +- Libra 负责仓库状态和 push。 +- `gh` 负责 GitHub PR 创建。 +- 不通过 shell 调用 `gh`。 +- 默认不静默 push,必须显式 `--push`。 +- 输出 Libra 自己的稳定 JSON schema。 +- 第一版只支持 GitHub,非 GitHub remote 明确拒绝。 + +这能在很低实现成本下获得长期可维护的 GitHub PR 能力,同时不把 Libra 绑定到一套自维护的 +GitHub API / auth 实现。 diff --git a/docs/development/tracing/plan.md b/docs/development/tracing/plan.md index 8641fc3b5..18da36658 100644 --- a/docs/development/tracing/plan.md +++ b/docs/development/tracing/plan.md @@ -1606,4 +1606,44 @@ cargo test --lib cli::tests::root_after_help_lists_every_visible_command | 2026-07-06 | Task C5(Session resume, graph handoff and persistence) | 完成 | 0.18.17 | 本行所在提交 | **GAP-2 决策落地**:按卡 :1309 + tracing code.md:48 保持 --resume TUI-only(web-only/stdio 均拒绝,不放宽),把 C2 遗留的「deferred to C5」文档改写为「TUI-only by design(永久契约,非延后)」。逐 criterion:**(1) --resume TUI-only + 错误测试**:web-only reject 已有 rejects_tui_flags_in_web_mode(消息含 flag+--web+remove),补 stdio 侧 rejects_resume_in_stdio_mode;code_resume_test(test-provider)4 真实 PTY 用例(happy/SIGTERM-mid-turn/unknown-uuid/unknown-non-uuid)全绿。**dead code**:load_or_create_headless_web_session_state 的 resume 分支经 --resume upstream 拒绝后 CLI 不可达,但 create 分支载荷可达——保留+文档注明「intentionally unreachable,TUI-only by design」(移除需重塑 helper 无收益)。**(3) graph handoff --repo 提示**(GAP-FIX):TUI exit 打印原只发 `libra graph <id>` 无 --repo,违 code.md:24 承诺;新 format_graph_handoff_hint(session_working_dir≠cwd 时 canonicalize 比较后追加 --repo,cwd 未知时 fail-safe 加 --repo)+ shell_quote_for_display(含空格/shell 元字符时 POSIX 单引号,`'`→`'\''`,否则裸值)+ 5 测试(同目录裸/远程加 --repo/未知 cwd 加 --repo/引用 helper 纯测/含空格路径加引号)。**(4) JSONL reader**:parse_session_event_value 跳过未知/缺 kind(Ok(None)),load_events 容忍尾行 truncated(无尾换行→warn+break)但对完整畸形行仍报错——ai_session_jsonl_test 4 绿。**(5) projection bundle identity**:run_tui_with_model_inner 按 canonical thread id 载 bundle,build_tui_code_ui_runtime 用 bundle identity,仅 None 时回退临时 session.id——tui_code_ui_runtime_prefers_projection_bundle_identity + ai_code_ui_projection_test 绿。**(6) audit sink**:web/mod.rs 经 runtime AuditSink 发 local-tui-control:<kind>:<client>/policy_version=local-tui-control/v1 的结构化 redacted ControlAuditRecord(非 transcript),TUI 路由 TracingAuditSink——InMemoryAuditSink control-attach 测试 pin。文档:code.md:117/241 + zh-CN + tracing code.md:26/75 全部从「deferred/lands later」改为「TUI-only by design」,移除 web-only --resume 示例。codex review 两轮:R1 FAIL 2P2(--repo 路径未 shell-quote 破坏含空格路径复制粘贴;tracing code.md 第二处 stale「web-only --resume→C5」)→修(shell_quote_for_display + 2 测试 + 修 stale 行)→R2 VERDICT: PASS 零发现。门禁:fmt/clippy --all-targets --all-features -D warnings/code:: 91 单测/code_resume_test 11(test-provider)/ai_session_jsonl 4/ai_code_ui_projection 2/ai_goal_resume 3 全绿 | | 2026-07-06 | Task C6(MCP stdio and code-control boundary) | 完成 | 0.18.17 | 本行所在提交 | 验证型卡(C1 判定该面已一致)。逐 criterion:**(1) `libra code --stdio` = MCP-only 不控 live TUI(satisfied-as-is)**:execute_stdio(code.rs:4076-4103)只 init_mcp_server + serve_server(AsyncRwTransport stdio),无 TUI/AgentRuntime attach;`--control write` 在 `--stdio` 下被 validate_mode_args 拒绝并指向 `code-control --stdio`(code.rs:4160-4166);unit pin:rejects_control_write_in_stdio_mode(断言含「code-control --stdio」)、stdio_mode_stays_provider_locked、rejects_web_flags_in_stdio_mode、rejects_resume_in_stdio_mode。新增的 stdio 集成测试进一步实证运行期只走 MCP 传输。**(2) `code-control --stdio` = token/lease-gated automation 入口(satisfied-as-is)**:execute 要求 `--stdio`(code_control.rs:144);controller.attach 用 process control token(x-libra-control-token)换取 controller/lease token,message.submit/interaction.respond/turn.cancel/task.dispatch/goal.start/goal.cancel 同时转发两枚 token(Some(&controller_token),code_control.rs:289-393);json_rpc_dispatch_maps_attach_submit_and_detach_to_http 固定 token 转发;server 端强制(--control observe→403 CONTROL_DISABLED、messages-route-before-controller-token inline)由 code_ui_remote_security_matrix 守卫。**(3) docs 不把 MCP stdio 当 turn control plane(satisfied-as-is)**:跑卡内 grep 人工复核 docs/commands + tracing/code.md + tracing/agent.md + tracing/plan.md + src/command,全部命中要么正确描述为 MCP transport / tool surface(code.rs:40 "MCP tool surface only"、code.md(commands):18/212),要么显式否定该误读——code.md(commands):90「libra code --stdio remains the MCP stdio server and does not control a live TUI」、:330、code-control.md:8、tracing code.md:52/77、agent.md:551、plan.md:61/1480「MCP stdio 不得成为 turn control plane」;plan.md:23 提到的 memory.md `libra mcp --stdio` 冲突按 §0 out-of-scope 声明处理且本身不误述。**无 doc 把 MCP stdio 描述成 live-TUI/AgentRuntime turn control plane,criterion 3 无需修文档。** GAP-5(run_libra_vcs allowlist 文档漂移:code.md:291/293 少列 show-ref/ls-files 且反向禁止 ls-files)C6-relevant 但按卡归 C8,本卡不动。**(4) dual-entry tool set/error/shutdown 回归(GAP-FIX)**:原 code_mcp_dual_entry_test 只驱动 MCP **HTTP** 传输 + clap `--stdio`/`--web-only` 互斥;真正的 `--stdio` MCP 传输的 tool-set/error/shutdown 无回归。新增 2 个 test-provider-gated 用例:**libra_code_stdio_serves_tool_surface_reports_errors_and_shuts_down**——用子进程驱动真实 `libra code --stdio` 的换行分隔 JSON-RPC:tools/list 暴露共享工具面(断言含 run_libra_vcs/create_task/list_tasks/create_intent 且 ≥10 工具)、未知 method→-32601 顶层 error、未知 tool→invalid_params 顶层 error、stdin EOF→干净 exit 0(30s 看门狗把 shutdown 回归转成失败而非挂起);**mcp_http_and_stdio_expose_identical_tool_set**——断言 HTTP tools/list 集合 == stdio tools/list 集合(两入口共用 init_mcp_server→build_tool_router)。保留该文件「裸跑=1 skip 占位」契约(两测试同 gated on test-provider)。仅改 tests/code_mcp_dual_entry_test.rs(无 src 改动)。门禁:fmt/clippy --all-targets --all-features -D warnings 全净;code_mcp_dual_entry_test 14 ok(test-provider,含新 2 例);code_ui_remote_security_matrix 13 ok;code:: 113 单测全绿 | | 2026-07-06 | Task C8(Code docs, compatibility and final closeout) | 完成 | 0.18.20 | 本行所在提交 | Code 阶段最终收尾(docs/compat/index,无 src 改动)。**GAP-6..12**(7 项 flag/UI docs-drift,C7 前先行):docs/commands/code.md+zh-CN 补 --goal(goal 模式,parse 时校验非空+≤16KiB)/--agent(三层 profile lookup,binding 原子胜 --model,未知拒绝)/--approval-ttl(秒,覆盖项目 [approval] ttl_seconds,默认 300)/--kimi-stream(默认 true,非 Kimi 拒绝)行,allow-all approval 行+「four-tier→five-tier」,--plan-mode 默认澄清(off;Codex 有效默认 on,显式 =true 仅 codex),zh-CN browser-control 路由矩阵同步 EN+code_router()(补 /goal/status、/task/dispatch、/goal/start、/goal/cancel,删不存在的 /repo*)。**GAP-5**(run_libra_vcs allowlist doc,C7 确认未改 allowlist):code.md+zh-CN 从 8 命令+禁 ls-files 改为权威 10 命令(status/diff/branch/log/show/show-ref/ls-files/add/commit/switch,per libra_vcs.rs:11-13)并推荐 ls-files(含 --others --exclude-standard),与工具自身 guidance 一致。**COMPATIBILITY.md**:code/code-control 行已存在 intentionally-different/Libra-only,matrix_alignment 守卫过,无需改。**tests/INDEX.md**:15 个 Code 阶段 target 行核对,2 行(code_codex_default_tui_test/code_codex_runtime_test)源路径 stale 指向不存在的 agent/codex*→改为 src/internal/ai/codex/+准确用途,13 行准确无改,未动 agent 阶段行。**tracing code.md**:1 行(Mode 与参数)仍以现在时声称 web-only 漂移风险(C2 已解决)→更新为 C2 放宽+CLI 回归覆盖;其余核对无 stale。**CHANGELOG.md**:加 Code 阶段收尾条 + 「Mutating fix bridge deferred」条(review --fix/investigate fix 保持只读、LBR-AGENT-010,无 agent↔code mutating 协作边界,待 fix bridge 落地)。codex review 三轮:R1 FAIL 1P1+1P2(--approval-ttl 文档写 approval.ttl 实为 ttl_seconds;--plan-mode 文档暗示非 Codex 可 =true 实为拒绝)→修(EN+zh)→R2 FAIL 1P2(zh --plan-mode=false 写「会话退出」应为「关闭计划模式」)→修→R3 VERDICT: PASS 零发现。门禁:compat_matrix_alignment 7/compat_command_docs_examples_section 1/compat_help_examples_banner 1/compat_error_codes_doc_sync 2/fmt 全绿;全量 cargo test --all 终审门在本机受阻:并行 spawn-based command 测试(fsck/shortlog)在 ~test 101 处 deadlock,跨 C4-C8 复现、与负载无关、零 FAILED、单跑均通过——环境限制非代码缺陷;C8 纯 docs,代码即 C7 已验证 HEAD,逐 doc-guard(matrix_alignment 7/command_docs 1/help_examples 1/error_codes 2)+ codex R3 PASS + fmt 全绿。**并发事故处理**:merge origin/main(含 #431 reset PR)时 libra CLI panic 在共享 index 留下 #431 的伪 revert(cli.rs/reset.rs/cherry_pick.rs/launcher.rs/reset docs/COMPATIBILITY.md staged 删除),libra reset 卸暂存后 working tree 仍带该 revert→libra restore --source HEAD 恢复 11 个非 C8 文件到 HEAD(HEAD 正确含 #431),确认仅 5 个 C8 文件 dirty 后再提交。**Code 阶段 C1-C8 全部完成。** | -| 2026-07-06 | Task AG-24 follow-up(Agent plan code-verified closeout) | 完成 | 0.18.21 | 本行所在提交 | 按用户要求重新从代码而非文档事实核验 `docs/development/tracing/plan.md` / `agent.md` 的 Agent Gate 8 状态:`src/cli.rs` 已有顶层 `review`/`investigate`;`src/command/agent/*` 已有 `list/add/remove`、`checkpoint export`、`clean --gc/--retention-days`、`doctor --repair`、`push --force-rewrite`、`rpc trust/untrust`;`observed_agents/registry.rs::supported_slugs()` 派生第一批 `claude-code`/`codex`/`opencode`,三者均 hook-installable / transcript-readable / read-only review/investigate launchable;`hooks/providers/{claude,codex,opencode}`、`LifecycleEventKind::SubagentStart/End`、checkpoint E4 writer/export/reader/doctor、raw `--allow-raw` + `agent_audit_log`、retention GC 与本地 erasure 测试均已落地。未发现当前 Gate 8 必须补代码的缺口;剩余项明确归为 deferred/future parity(`--local-dev`/`--force` public flags、dynamic top-level `libra hooks <agent> <verb>`、独立 `CheckpointScope::Subagent` 物化、provider-specific compaction/reassemble、mutating fix bridge、full `SkillDiscoverer`、非首批 agent 扩展、cloud mirror tombstone)。本轮改动收敛文档漂移并新增 `compat_agent_docs_contract::{agent_doc_tracks_schema_versioning_and_retention_policy,agent_doc_tracks_code_agent_runtime_source_of_truth}`,同步 `tests/INDEX.md` / `tests/compat/README.md`,防止 schema/retention/raw-export 约束和内部 runtime 事实源链接再次退化。 | + +## 11. 未完成功能项复核(2026-07-07) + +**复核时间**:2026-07-07(基线 HEAD 版本 `0.18.20`,§10 已把 Task 0.1–C8 全部标记「完成」之后的一次独立代码层复核)。 + +本节独立于 §10 进度表,记录在全部任务卡都已标记完成之后、通过对每张任务卡逐条验收标准做**代码层复核**(不以任务卡 `[x]` 或 §10「完成」行为准,遵循 §0 / §0.1.4 / §9「完成判定以代码为准」原则)发现的**未落地 / 契约漂移 / 文档-代码不一致**项。以下每项均以源码 `file:line` 为证据;`[x]` 与 §10 行是被复核的**声明**,不是完成证据。 + +**检查工具说明(每项标注由哪个工具查出)**: + +- **codex**:由 codex 独立静态审阅查出(UF-01 ~ UF-03)。本轮 Claude workflow 亦独立复现并对抗式验证(CONFIRMED),此三项归属 codex。 +- **Claude Code(多-agent 审计 workflow)**:本 session 对 A1–A9 / A8.5 / C1–C8 每张任务卡各派一名审计 agent + 一名对抗式验证 agent(`tracing-plan-completion-audit` workflow,51 agents,16 CONFIRMED / 15 REFUTED)查出并交叉验证(UF-04 ~ UF-11)。 + +**本轮复核覆盖说明**:C2 / C5 / C8 三卡零缺陷;`audit:A2`(AG-17 alias)与 `audit:A6.5`(本地三 agent smoke)两名审计 agent 中途 API stall 失败、**本轮未完成自动复核**(A6.5 本就依赖本机真实付费 agent、无法静态判定,需按 §0.3 现场跑)——这两卡的完整复核为**本节遗留项**,不代表已确认无缺口。 + +### 11.1 未完成功能项汇总表 + +| 编号 | 未完成项 | 关联卡 / 验收锚点 | 类别 | 严重度 | 检查工具 | 证据(file:line) | +|---|---|---|---|---|---|---| +| UF-01 | `agent.retention.stderr_days` 30 天 stderr/redaction-report blob 清理窗口未实现 | A8.5(plan.md:1044) | 功能缺口 | 高 | codex | `compliance.rs:79-87`(getter 零消费者);`clean.rs:87-115`(GC 仅用 `retention_transcript_days` 删整个 checkpoint);`agent.md:1866`(要求「删诊断 blob、保留聚合计数」的独立动作未实现) | +| UF-02 | `agent.retention.findings_days` review/investigate findings GC 未实现;deferred 前提失效;A9 release notes 未说明 | A8.5(plan.md:1045/1056)、A9(plan.md:1088) | 功能缺口 | 高 | codex | `compliance.rs:89-97`(getter 零消费者);`review.rs:160-168` / `investigate.rs` clean 仅 `--run/--all`;deferred 理由「A7/A8 未动工」已失效(A7=0.18.10、A8=0.18.12 均完成、A8 已建 `InvestigateRunStore` run-state);`CHANGELOG.md` 无该 deferral 说明 | +| UF-03 | codex `config_paths` 公共 JSON 契约漂移:`agent list --json` 暴露 `.codex/hooks.json`,Libra 实际不写该路径 | A1(plan.md:640)、A4、§0.3.3 | 契约漂移 | 中 | codex | `registry.rs:170`(`config_paths=[".codex/hooks.json"]`)vs 同行注释 `registry.rs:158-162` + 安装器 `codex/settings.rs:156-238`(实写用户级 `$CODEX_HOME/hooks.json`+`config.toml`,不写 project 路径,且字段漏 `config.toml`);`list.rs:100` 直出 JSON;pin 测试 `agent_capability_matrix_pin.rs:98` 钉死错误值;`agent.md:1711`「不得默认写未证实的 `.codex/hooks.json`」 | +| UF-04 | A9 发布说明 / `CHANGELOG` 缺整个「已启用 Agent 面」+ 迁移 + 错误码 + D1/R2 deferred 说明 | A9(plan.md:1081/1088、1046) | 功能缺口(发布门禁) | 高 | Claude Code(多-agent 审计 workflow) | `CHANGELOG.md` [Unreleased] 仅「Mutating fix bridge deferred」一条 agent 项;已启用的外部 agent 捕获(claude-code/codex/opencode)、agent trace/checkpoint、review/investigate workflow、`--allow-raw`+`agent_audit_log`、retention GC、external discovery opt-in、D1/R2 deletion-propagation deferred 均无 release note;新迁移 `2026070803_agent_audit_log`、错误码 `LBR-AGENT-013` 无 migration/release note(`agent.md:1378` 要求区分三类 deferral) | +| UF-05 | `SubagentStart`/`SubagentEnd` 的 `subagent_events` metadata 投影为死代码(已启用捕获路径不生效) | A4(plan.md:753 及 §10 A4 行)、`agent.md:277` | 功能缺口(死代码) | 中 | Claude Code(多-agent 审计 workflow) | 投影仅在 `lifecycle.rs:401-423`(经 AiIntent 入口 `runtime.rs:162` `process_hook_event_from_stdin`,全仓库零调用者);codex(唯一发该事件,`parser.rs:93-94`)两个 hook 入口均路由 AgentTraces(`hooks.rs:146`、`agent/hooks.rs:127`),其 metadata 由 `runtime.rs:814-842` 只投影 transcript_path/concurrent_active、checkpoint 仅 SessionEnd\|TurnEnd 写(`runtime.rs:751-754`);`subagent_events` 全仓库无读取者 | +| UF-06 | `libra code` 缺-key 错误消息打印**无法解析**的配置命令 | C3(criterion 2) | 功能缺口(错误消息不可用) | 中 | Claude Code(多-agent 审计 workflow) | `code.rs:1341-1344` 打印 `libra config --global add vault.env.{var} <value>`,但 `config` 无 `add` 子命令(`add` 只是隐藏 `--add` bool,`config.rs:302-304`),该命令解析失败 exit 129;正确形态 `libra config set vault.env.{var} <value>`(`internal/config.rs:1016` `resolve_required_env_sync` 及所有其它 provider 均用之);守卫测试 `code.rs:6059-6064` 只断言 `contains("vault.env")`,钉死坏命令 | +| UF-07 | `code.md` 文档的 Vault vs 进程环境优先级与实现相反 | C3(`code.md:49` Vault/env lookup 固定范围) | 文档-代码漂移 | 中 | Claude Code(多-agent 审计 workflow) | `docs/commands/code.md:80`(及 zh-CN:80)称「先 repo-local Vault → global Vault → 进程环境」,实现 `resolve_env_sync`(`internal/config.rs:988-1002`)先查进程环境再回落 vault(`config.rs:1042-1055` 注释自称进程环境为 per-process override、global 为 lowest priority);stale env + 新 vault 值场景下文档承诺 vault 胜、代码返回 stale env 值 | +| UF-08 | `agent.external_agents.{trusted_dirs,approved_binaries,env_allowlist_extra}` 为幽灵设置(文档列具体消费者,代码无一实现) | §0 issue #9、`agent.md:1232-1234` | 文档-代码漂移 | 中 | Claude Code(多-agent 审计 workflow) | `trusted_dirs` 标消费者 `libra agent rpc trust --dir`,但 `AgentRpcTrustArgs`(`command/agent/rpc.rs:60-63`)只有 slug 位参、无 `--dir`,发现走 $PATH 扫描;`approved_binaries` 真实存储是 `agent.trust.<slug>`(`trust.rs:27`);`env_allowlist_extra` 无消费者,spawn 硬编码 3 变量 allowlist(`observed_agents/rpc.rs:277-287`);仅 `agent.external_agents.enabled` 被消费 | +| UF-09 | `libra code --repo` flag 未进 `code.md` 权威 flags 表(EN+zh),C1 差距清单遗漏 | C1(plan.md:1172/1176)、C8 收尾 | 文档漂移 | 低 | Claude Code(多-agent 审计 workflow) | `code.rs:476-478` 定义非隐藏 flag `--repo`;`docs/commands/code.md:28-61` 与 zh-CN flags 表均无 `--repo` 行(仅示例 `code.md:241/274` 出现);C1 GAP-1..12 未列、C8 未补 | +| UF-10 | A5 验证清单陈旧计数:`agent_doctor_repair_test(8)` 实为 13 | A5(plan.md:828) | 文档-测试漂移(良性) | 低 | Claude Code(多-agent 审计 workflow) | `plan.md:828` 记 8;`tests/agent_doctor_repair_test.rs` 实有 13 用例(§10 A5 行 `plan.md:1593` 已记 13,验证清单行未同步);覆盖更多非缺口 | +| UF-11 | retention 默认值测试只钉常量、未演练 GC 消费者(stderr/findings 窗口零执行,造成覆盖假象) | A8.5(plan.md:1044「有测试」) | 测试漂移 | 低 | Claude Code(多-agent 审计 workflow) | `tests/agent_audit_log_test.rs:156-158` 仅断言三个 `DEFAULT_RETENTION_*_DAYS` 常量值,未调用 getter 或任何 GC 消费者;与 UF-01/UF-02 同源 | + +### 11.2 处置建议(供后续任务卡承接) + +- **UF-01 / UF-02 / UF-11(A8.5 retention GC 面)**:重开 A8.5 收尾切片——(a) 在 `clean --gc` 增加独立 `stderr_days`(默认 30)窗口,动作为「删除 stderr/redaction-report 诊断 blob、保留聚合计数」而非删整个 checkpoint;(b) 落地 `findings_days`(默认 90)对 `.libra/sessions/agent-runs/<run_id>/` run-state/findings 的 GC(并入 `clean --gc` 或 `review/investigate clean`——A7/A8 结构现已就绪,deferred 前提已消失);(c) 补真正演练两窗口的行为测试,替换纯常量 pin。 +- **UF-03(config_paths 契约)**:把 codex 行 `config_paths` 改为 Libra 实际托管面(用户级 `$CODEX_HOME/hooks.json` + `$CODEX_HOME/config.toml`,或按字段语义重定义并统一三 agent 口径),同步更新 `agent_capability_matrix_pin.rs:98`、`agent.md:226` 与 `agent list --json` 契约说明。 +- **UF-04(发布说明)**:在 `CHANGELOG.md` 补「已启用 Agent 捕获面」条目,并按 A9/`agent.md:1378` 显式区分 enabled / preview-opt-in(external discovery)/ explicitly-deferred(review/investigate `--fix`、D1/R2 deletion propagation、findings-GC),补 `2026070803_agent_audit_log` 迁移与 `LBR-AGENT-013` 错误码的 release/migration note。 +- **UF-05(subagent_events 死代码)**:要么让 AgentTraces ingest(`runtime.rs:814-842`)在 SubagentStart/End 上真正投影 capped `subagent_events`,要么删除死投影并把 §10/`agent.md:277` 的「已实现」表述降级为未落地。 +- **UF-06 / UF-07(`libra code` env 面)**:把缺-key 错误消息改为可解析的 `libra config set vault.env.<VAR> <value>`(并让守卫测试断言命令可解析);修正 `code.md:80`/zh-CN 的 Vault-vs-进程环境优先级描述与实现一致(进程环境优先)。 +- **UF-08(幽灵设置)**:删除 `agent.md:1232-1234` 三个无代码支撑的 settings 行,或补齐其消费者(`--dir` 参数、`approved_binaries` 键、`env_allowlist_extra` 透传)。 +- **UF-09 / UF-10(文档/测试小漂移)**:`code.md`+zh-CN flags 表补 `--repo` 行;`plan.md:828` 的 `(8)` 校正为 `(13)`。 + +**说明**:本节为复核记录,不修改 §10 既有「完成」行(§10 为 append-only 历史);后续按 §0.4/§0.5 承接上述项时,在 §10 追加对应发布行。A2 / A6.5 两卡的完整复核为本节遗留项,需在后续单独补做。 From fb8b86bc6938f369b9a2c53c6429252b00585d56 Mon Sep 17 00:00:00 2001 From: Eli Ma <eli@patch.sh> Date: Tue, 7 Jul 2026 16:19:40 +0800 Subject: [PATCH 09/12] fix(status): honor ignorecase override in wrappers Signed-off-by: Eli Ma <eli@patch.sh> --- src/command/status.rs | 16 +++++++-- src/utils/path_case.rs | 50 ++++++++++++++++++++++---- tests/command/case_handling_test.rs | 55 +++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/command/status.rs b/src/command/status.rs index 91041c009..2dbe3714e 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -281,6 +281,8 @@ pub enum StatusError { ListWorkdirFiles { path: PathBuf, source: io::Error }, #[error("failed to determine working directory: {source}")] Workdir { source: io::Error }, + #[error("{source}")] + ConfigRead { source: anyhow::Error }, } impl From<StatusError> for CliError { @@ -302,6 +304,9 @@ impl From<StatusError> for CliError { StatusError::Workdir { .. } => { CliError::fatal(msg).with_stable_code(StableErrorCode::RepoNotFound) } + StatusError::ConfigRead { .. } => { + CliError::fatal(msg).with_stable_code(StableErrorCode::IoReadFailed) + } } } } @@ -2575,7 +2580,7 @@ pub fn changes_to_be_staged() -> Result<Changes, StatusError> { /// Commands such as `add --force` or `status --ignored` can switch policies as needed. pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result<Changes, StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; - let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + let ignore_case = effective_ignore_case_for_workdir(&workdir)?; changes_to_be_staged_with_policy_and_ignore_case(policy, ignore_case) } @@ -2609,7 +2614,7 @@ fn changes_to_be_staged_with_policy_and_ignore_case( pub fn changes_to_be_staged_split_safe() -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; - let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + let ignore_case = effective_ignore_case_for_workdir(&workdir)?; changes_to_be_staged_split_safe_with_ignore_case(ignore_case) } @@ -2628,10 +2633,15 @@ pub(crate) fn changes_to_be_staged_split_safe_with_ignore_case( /// List changes to be staged with --force semantics (recurse into ignored directories) pub fn changes_to_be_staged_split_force() -> Result<(Changes, Changes), StatusError> { let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; - let ignore_case = crate::utils::path_case::probe_dir_ignore_case(&workdir); + let ignore_case = effective_ignore_case_for_workdir(&workdir)?; changes_to_be_staged_split_force_with_ignore_case(ignore_case) } +fn effective_ignore_case_for_workdir(workdir: &Path) -> Result<bool, StatusError> { + crate::utils::path_case::effective_ignore_case_for_dir_sync(workdir) + .map_err(|source| StatusError::ConfigRead { source }) +} + pub(crate) fn changes_to_be_staged_split_force_with_ignore_case( ignore_case: bool, ) -> Result<(Changes, Changes), StatusError> { diff --git a/src/utils/path_case.rs b/src/utils/path_case.rs index 045a9da9f..b3a0fd8ed 100644 --- a/src/utils/path_case.rs +++ b/src/utils/path_case.rs @@ -134,6 +134,16 @@ pub fn is_same_file_case_alias(workdir: &Path, candidate: &Path, tracked: &Path) && same_file_entry(&workdir.join(candidate), &workdir.join(tracked)) } +fn parse_ignore_case_value(value: &str) -> Result<bool> { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(true), + "false" | "no" | "off" | "0" | "" => Ok(false), + other => Err(anyhow!( + "unsupported core.ignorecase '{other}' (expected a boolean)" + )), + } +} + /// The repo's EFFECTIVE case-insensitivity: explicit `core.ignorecase` /// (git-bool, invalid = hard error) wins; otherwise a per-process runtime /// probe of the workdir; missing workdir → false (guards no-op). @@ -142,17 +152,43 @@ pub async fn effective_ignore_case() -> Result<bool> { .await .map_err(|error| anyhow!("failed to read core.ignorecase: {error}"))?; if let Some(entry) = entry { - return match entry.value.trim().to_ascii_lowercase().as_str() { - "true" | "yes" | "on" | "1" => Ok(true), - "false" | "no" | "off" | "0" | "" => Ok(false), - other => Err(anyhow!( - "unsupported core.ignorecase '{other}' (expected a boolean)" - )), - }; + return parse_ignore_case_value(&entry.value); } Ok(probe_workdir_ignore_case()) } +/// Synchronous counterpart for status helpers that are intentionally sync. +/// +/// It honors the explicit `core.ignorecase` override first and falls back to an +/// uncached probe of the supplied workdir, so callers do not accidentally reuse +/// the process-wide probe for a different repository. +pub fn effective_ignore_case_for_dir_sync(dir: &Path) -> Result<bool> { + let value = core_ignorecase_value_sync()?; + match value { + Some(value) => parse_ignore_case_value(&value), + None => Ok(probe_dir_ignore_case(dir)), + } +} + +fn core_ignorecase_value_sync() -> Result<Option<String>> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = (|| -> Result<Option<String>> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|err| anyhow!("failed to create tokio runtime: {err}"))?; + runtime.block_on(async { + ConfigKv::get_var_case_insensitive("core.", "ignorecase") + .await + .map(|entry| entry.map(|entry| entry.value)) + }) + })(); + let _ = tx.send(result); + }); + rx.recv() + .map_err(|_| anyhow!("core.ignorecase sync worker exited unexpectedly"))? + .map_err(|error| anyhow!("failed to read core.ignorecase: {error}")) +} + /// Runtime probe (cached per process): does the working directory's /// filesystem resolve a case-swapped `.libra` spelling to the same entry? pub fn probe_workdir_ignore_case() -> bool { diff --git a/tests/command/case_handling_test.rs b/tests/command/case_handling_test.rs index 682ecad8c..d5b43744f 100644 --- a/tests/command/case_handling_test.rs +++ b/tests/command/case_handling_test.rs @@ -8,6 +8,9 @@ //! //! **Layer:** L1 — deterministic. +use libra::utils::ignore::IgnorePolicy; +use serial_test::serial; + use super::*; fn case_repo() -> tempfile::TempDir { @@ -30,6 +33,58 @@ fn skip_case_twin_fixture_on_case_insensitive_host(p: &std::path::Path, scenario false } +#[tokio::test] +#[serial] +async fn status_sync_wrappers_honor_core_ignorecase_override() { + let repo = case_repo(); + let p = repo.path(); + let host_ignore_case = libra::utils::path_case::probe_dir_ignore_case(p); + let expected_alias_is_new = host_ignore_case; + let configured_ignorecase = if host_ignore_case { "false" } else { "true" }; + assert_cli_success( + &run_libra_command(&["config", "core.ignorecase", configured_ignorecase], p), + "force ignorecase", + ); + if host_ignore_case { + fs::rename(p.join("Foo.txt"), p.join("case-tmp.txt")).unwrap(); + fs::rename(p.join("case-tmp.txt"), p.join("foo.txt")).unwrap(); + } else if let Err(error) = fs::hard_link(p.join("Foo.txt"), p.join("foo.txt")) { + eprintln!("skipping ignorecase override status wrapper test: hard link failed: {error}"); + return; + } + let _guard = ChangeDirGuard::new(p); + + let unstaged = libra::command::status::changes_to_be_staged_with_policy(IgnorePolicy::Respect) + .expect("status policy wrapper should read worktree"); + assert_eq!( + unstaged.new.iter().any(|path| path == Path::new("foo.txt")), + expected_alias_is_new, + "core.ignorecase={configured_ignorecase} should control same-file case aliases in policy wrapper: {unstaged:?}" + ); + + let (safe_visible, _) = libra::command::status::changes_to_be_staged_split_safe() + .expect("safe split wrapper should read worktree"); + assert_eq!( + safe_visible + .new + .iter() + .any(|path| path == Path::new("foo.txt")), + expected_alias_is_new, + "core.ignorecase={configured_ignorecase} should control same-file case aliases in safe split wrapper: {safe_visible:?}" + ); + + let (force_visible, _) = libra::command::status::changes_to_be_staged_split_force() + .expect("force split wrapper should read worktree"); + assert_eq!( + force_visible + .new + .iter() + .any(|path| path == Path::new("foo.txt")), + expected_alias_is_new, + "core.ignorecase={configured_ignorecase} should control same-file case aliases in force split wrapper: {force_visible:?}" + ); +} + #[test] fn init_records_probed_ignorecase() { let repo = create_committed_repo_via_cli(); From 7bf699099ed73fb15043425583dd98c57d4682a6 Mon Sep 17 00:00:00 2001 From: Eli Ma <eli@patch.sh> Date: Tue, 7 Jul 2026 16:36:22 +0800 Subject: [PATCH 10/12] ci(web): align pnpm version with web package Signed-off-by: Eli Ma <eli@patch.sh> --- .github/workflows/base.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index 4d2549e83..9544d34e0 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -57,7 +57,7 @@ jobs: - name: Enable pnpm run: | corepack enable - corepack prepare pnpm@11.1.0 --activate + corepack prepare pnpm@11.10.0 --activate - name: Run cargo clippy run: cargo clippy --all-targets --all-features -- -D warnings @@ -83,7 +83,7 @@ jobs: - name: Enable pnpm run: | corepack enable - corepack prepare pnpm@11.1.0 --activate + corepack prepare pnpm@11.10.0 --activate # The Rust crate's `build.rs` skips the web build whenever # `LIBRA_SKIP_WEB_BUILD=1` (set on every other job for speed). This @@ -166,7 +166,7 @@ jobs: - name: Enable pnpm run: | corepack enable - corepack prepare pnpm@11.1.0 --activate + corepack prepare pnpm@11.10.0 --activate - name: Ensure ripgrep is available shell: bash @@ -292,7 +292,7 @@ jobs: - name: Enable pnpm run: | corepack enable - corepack prepare pnpm@11.1.0 --activate + corepack prepare pnpm@11.10.0 --activate # Network-only test layer (`test-network` feature gate). Tests requiring outbound # network but no secrets are surfaced through this job; tests requiring real diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e50a533d9..c9d52fb99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Enable pnpm run: | corepack enable - corepack prepare pnpm@11.1.0 --activate + corepack prepare pnpm@11.10.0 --activate - name: Install web dependencies run: pnpm --dir web install --frozen-lockfile From 36d520417cf00c3f7aad8cedef4134e44d2e0f62 Mon Sep 17 00:00:00 2001 From: Eli Ma <eli@patch.sh> Date: Tue, 7 Jul 2026 23:53:24 +0800 Subject: [PATCH 11/12] fix(status): stabilize ignorecase sync lookup Signed-off-by: Eli Ma <eli@patch.sh> --- src/command/add.rs | 21 +----- src/command/status_untracked_paths.rs | 22 +----- src/utils/path_case.rs | 101 ++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/command/add.rs b/src/command/add.rs index 72debe10f..2c262faa7 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -1250,26 +1250,7 @@ fn pathspec_contains(candidate_abs: &Path, requested_abs: &Path, ignore_case: bo if util::is_sub_path(candidate_abs, requested_abs) { return true; } - ignore_case && path_starts_with_casefold(candidate_abs, requested_abs) -} - -fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { - let mut path_components = path.components(); - for parent_component in parent.components() { - let Some(path_component) = path_components.next() else { - return false; - }; - let path_key = crate::utils::path_case::fold_path_key( - path_component.as_os_str().to_string_lossy().as_ref(), - ); - let parent_key = crate::utils::path_case::fold_path_key( - parent_component.as_os_str().to_string_lossy().as_ref(), - ); - if path_key != parent_key { - return false; - } - } - true + ignore_case && crate::utils::path_case::path_starts_with_casefold(candidate_abs, requested_abs) } /// True iff any path in `candidates` (interpreted relative to `workdir`) is a diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index f31fa2682..7202cb0a7 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -68,7 +68,8 @@ impl TrackedPaths { } self.files.iter().any(|file| { file.starts_with(dir) - || (self.case_aliases_enabled && path_starts_with_casefold(file, dir)) + || (self.case_aliases_enabled + && crate::utils::path_case::path_starts_with_casefold(file, dir)) }) } @@ -83,25 +84,6 @@ impl TrackedPaths { } } -fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { - let mut path_components = path.components(); - for parent_component in parent.components() { - let Some(path_component) = path_components.next() else { - return false; - }; - let path_key = crate::utils::path_case::fold_path_key( - path_component.as_os_str().to_string_lossy().as_ref(), - ); - let parent_key = crate::utils::path_case::fold_path_key( - parent_component.as_os_str().to_string_lossy().as_ref(), - ); - if path_key != parent_key { - return false; - } - } - true -} - pub(crate) fn collapse_untracked_directories( untracked_files: Vec<PathBuf>, tracked: &TrackedPaths, diff --git a/src/utils/path_case.rs b/src/utils/path_case.rs index b3a0fd8ed..e61015ab1 100644 --- a/src/utils/path_case.rs +++ b/src/utils/path_case.rs @@ -25,7 +25,10 @@ use std::{collections::HashMap, path::Path}; use anyhow::{Result, anyhow}; -use crate::internal::config::ConfigKv; +use crate::{ + internal::{config::ConfigKv, db::establish_connection}, + utils::util, +}; /// The case-handling policy (lore.md 1.14). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -72,6 +75,25 @@ pub fn fold_path_key(path: &str) -> String { path.chars().flat_map(char::to_lowercase).collect() } +/// Component-aware case-folded prefix check for paths. +/// +/// This intentionally compares path components instead of folded path strings, +/// so `FooBar` is not treated as a descendant of `foo`. +pub fn path_starts_with_casefold(path: &Path, parent: &Path) -> bool { + let mut path_components = path.components(); + for parent_component in parent.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + let path_key = fold_path_key(path_component.as_os_str().to_string_lossy().as_ref()); + let parent_key = fold_path_key(parent_component.as_os_str().to_string_lossy().as_ref()); + if path_key != parent_key { + return false; + } + } + true +} + /// Whether two paths differ ONLY by case (fold-equal but byte-different). pub fn is_case_only_pair(a: &str, b: &str) -> bool { a != b && fold_path_key(a) == fold_path_key(b) @@ -163,23 +185,50 @@ pub async fn effective_ignore_case() -> Result<bool> { /// uncached probe of the supplied workdir, so callers do not accidentally reuse /// the process-wide probe for a different repository. pub fn effective_ignore_case_for_dir_sync(dir: &Path) -> Result<bool> { - let value = core_ignorecase_value_sync()?; + let value = core_ignorecase_value_sync(dir)?; match value { Some(value) => parse_ignore_case_value(&value), None => Ok(probe_dir_ignore_case(dir)), } } -fn core_ignorecase_value_sync() -> Result<Option<String>> { +fn core_ignorecase_value_sync(dir: &Path) -> Result<Option<String>> { + let storage = util::try_get_storage_path(Some(dir.to_path_buf())).map_err(|error| { + anyhow!( + "failed to resolve repository storage for {}: {error}", + dir.display() + ) + })?; + let db_path = storage.join(util::DATABASE); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = (|| -> Result<Option<String>> { let runtime = tokio::runtime::Runtime::new() .map_err(|err| anyhow!("failed to create tokio runtime: {err}"))?; runtime.block_on(async { - ConfigKv::get_var_case_insensitive("core.", "ignorecase") - .await - .map(|entry| entry.map(|entry| entry.value)) + let db_path_text = db_path.to_str().ok_or_else(|| { + anyhow!( + "repository database path is not valid UTF-8: {}", + db_path.display() + ) + })?; + let db = establish_connection(db_path_text).await.map_err(|error| { + anyhow!( + "failed to open repository database {}: {error}", + db_path.display() + ) + })?; + let value = + ConfigKv::get_var_case_insensitive_with_conn(&db, "core.", "ignorecase") + .await + .map(|entry| entry.map(|entry| entry.value)); + db.close().await.map_err(|error| { + anyhow!( + "failed to close repository database {}: {error}", + db_path.display() + ) + })?; + value }) })(); let _ = tx.send(result); @@ -266,6 +315,8 @@ pub async fn guard_tree_case_collisions( #[cfg(test)] mod tests { + use serial_test::serial; + use super::*; #[test] @@ -279,6 +330,22 @@ mod tests { assert!(!is_case_only_pair("foo.txt", "bar.txt")); } + #[test] + fn casefold_prefix_check_is_component_aware() { + assert!(path_starts_with_casefold( + Path::new("Slides/Deck.md"), + Path::new("slides") + )); + assert!(path_starts_with_casefold( + Path::new("Slides"), + Path::new("slides") + )); + assert!(!path_starts_with_casefold( + Path::new("SlidesExtra/Deck.md"), + Path::new("slides") + )); + } + #[test] fn collision_groups_not_pairs() { let paths = ["Foo", "foo", "FOO", "bar", "Baz", "baz"]; @@ -352,4 +419,26 @@ mod tests { )); } } + + #[tokio::test] + #[serial] + async fn sync_ignore_case_uses_supplied_repo_dir_not_process_cwd() { + let repo = tempfile::tempdir().unwrap(); + crate::utils::test::setup_with_new_libra_in(repo.path()).await; + + { + let _guard = crate::utils::test::ChangeDirGuard::new(repo.path()); + ConfigKv::set("core.ignorecase", "true", false) + .await + .unwrap(); + } + + let outside = tempfile::tempdir().unwrap(); + let _outside_guard = crate::utils::test::ChangeDirGuard::new(outside.path()); + + assert!( + effective_ignore_case_for_dir_sync(repo.path()).unwrap(), + "sync lookup must use the explicit repo dir, not the process cwd" + ); + } } From 2012b5ecd2b6ecf6c022988a391f2bbb4ff48d65 Mon Sep 17 00:00:00 2001 From: Eli Ma <eli@patch.sh> Date: Wed, 8 Jul 2026 08:48:56 +0800 Subject: [PATCH 12/12] test(status): avoid sha1-only hash fixture Signed-off-by: Eli Ma <eli@patch.sh> --- src/command/status_untracked_paths.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/command/status_untracked_paths.rs b/src/command/status_untracked_paths.rs index 7202cb0a7..f444061f6 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -144,7 +144,7 @@ fn top_level_dir(path: &Path) -> Option<PathBuf> { #[cfg(test)] mod tests { use git_internal::{ - hash::ObjectHash, + hash::{ObjectHash, get_hash_kind}, internal::index::{Index, IndexEntry}, }; @@ -152,10 +152,13 @@ mod tests { fn index_with_paths(paths: &[&str]) -> Index { let mut index = Index::new(); + let hash_bytes = vec![1; get_hash_kind().size()]; + let object_hash = ObjectHash::from_bytes(&hash_bytes) + .expect("test hash length matches the active hash kind"); for path in paths { index.add(IndexEntry::new_from_blob( (*path).to_string(), - ObjectHash::new(&[1; 20]), + object_hash, 0, )); }