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 e676e9bcc..6e14027fd 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 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 实现。 diff --git a/docs/development/tracing/sandbox.md b/docs/development/tracing/sandbox.md index af70be420..99d14256d 100644 --- a/docs/development/tracing/sandbox.md +++ b/docs/development/tracing/sandbox.md @@ -6,7 +6,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`) --- @@ -24,6 +24,8 @@ 整个改动**复用现有 spawn/stdio/timeout/evidence 管线**:新后端本质上只是把 `seatbelt-exec … -- <cmd>` 这层 argv 包装换成 `container exec … -- <cmd>`,加上一套会话级 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. 目标与范围 @@ -737,6 +739,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<String>, + pub env: BTreeMap<String, String>, + pub writable_roots: Vec<PathBuf>, + pub deny_read_paths: Vec<PathBuf>, + 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. 里程碑、测试矩阵与验收 @@ -776,6 +872,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/src/command/add.rs b/src/command/add.rs index a7288ceda..2c262faa7 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -345,6 +345,13 @@ struct ValidatedPathspecs { missing: Vec<String>, } +#[derive(Clone, Copy)] +struct PathspecMatchContext<'a> { + workdir: &'a Path, + current_dir: &'a Path, + ignore_case: bool, +} + // --------------------------------------------------------------------------- // Public entry points // --------------------------------------------------------------------------- @@ -508,11 +515,23 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { 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 })? + 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()); @@ -522,8 +541,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { let validated = validate_pathspecs( &args.pathspec, &requested_paths, - &workdir, - ¤t_dir, + pathspec_ctx, &visible_changes, &ignored_changes, &index, @@ -544,12 +562,8 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { // --- 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 +587,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { // `--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 +678,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, true, &mut add_output, )?; @@ -684,11 +692,6 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { // (`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 +713,14 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { 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 +797,7 @@ pub async fn run_add(args: &AddArgs) -> CliResult<AddOutput> { &mut index, target_mode, &validated.files, - &workdir, - ¤t_dir, + pathspec_ctx, false, &mut add_output, )?; @@ -825,17 +835,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 +854,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 +862,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 +1160,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 +1182,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 +1246,24 @@ 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 && crate::utils::path_case::path_starts_with_casefold(candidate_abs, requested_abs) +} + /// 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 +1273,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<PathBuf> { 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 +1294,9 @@ fn filter_candidates( fn filter_refresh_candidates( files: &[PathBuf], requested_paths: &[PathBuf], - workdir: &Path, - current_dir: &Path, + pathspec_ctx: PathspecMatchContext<'_>, ) -> Vec<PathBuf> { - 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..2dbe3714e 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}; @@ -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) + } } } } @@ -393,6 +398,7 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult<StatusData> { .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 +412,12 @@ async fn collect_status_data(args: &StatusArgs) -> CliResult<StatusData> { .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 +726,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<bool> { + 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 +2579,29 @@ pub fn changes_to_be_staged() -> Result<Changes, StatusError> { /// 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<Changes, StatusError> { + let workdir = util::try_working_dir().map_err(|source| StatusError::Workdir { source })?; + let ignore_case = effective_ignore_case_for_workdir(&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, StatusError> { + 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<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, })?; - 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,33 +2613,56 @@ pub fn changes_to_be_staged_with_policy(policy: IgnorePolicy) -> Result<Changes, } 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 = effective_ignore_case_for_workdir(&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 = 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> { 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(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2634,7 +2691,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 +2700,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); } } @@ -2652,10 +2711,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(&tracked_files, ignore_case); for file in tracked_files.iter() { let file_str = file .to_str() @@ -2683,7 +2744,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 +2753,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(tracked_files: &[PathBuf], ignore_case: bool) -> HashMap<String, PathBuf> { + if !ignore_case { + 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<String, PathBuf>, +) -> 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<PathBuf>, Vec<PathBuf>)> { 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..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<StatusWorktreeChanges, 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 })?; @@ -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(); @@ -179,7 +180,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 +200,7 @@ fn scan_file( scan: &mut WorkdirScan, workdir: &Path, index: &Index, + tracked_paths: &TrackedPaths, path: &Path, relative: &Path, include_ignored: bool, @@ -200,7 +210,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..f444061f6 100644 --- a/src/command/status_untracked_paths.rs +++ b/src/command/status_untracked_paths.rs @@ -7,19 +7,50 @@ use git_internal::internal::index::Index; pub(crate) struct TrackedPaths { files: Vec<PathBuf>, + case_aliases_enabled: bool, + files_by_fold: HashMap<String, PathBuf>, top_level_dirs: HashSet<PathBuf>, + top_level_dirs_by_fold: HashMap<String, PathBuf>, } 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 top_level_dirs = files .iter() .filter_map(|path| top_level_dir(path)) .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, + files_by_fold, top_level_dirs, + top_level_dirs_by_fold, } } @@ -29,9 +60,27 @@ 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 + && crate::utils::path_case::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; } - self.files.iter().any(|file| file.starts_with(dir)) + 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) + }) } } @@ -91,3 +140,44 @@ fn top_level_dir(path: &Path) -> Option<PathBuf> { let first = PathBuf::from(components.next()?.as_os_str()); components.next().map(|_| first) } + +#[cfg(test)] +mod tests { + use git_internal::{ + hash::{ObjectHash, get_hash_kind}, + internal::index::{Index, IndexEntry}, + }; + + use super::*; + + 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(), + object_hash, + 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"))); + } +} diff --git a/src/utils/path_case.rs b/src/utils/path_case.rs index 949c7d031..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) @@ -123,6 +145,27 @@ 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)) +} + +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). @@ -131,17 +174,70 @@ 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(dir)?; + match value { + Some(value) => parse_ignore_case_value(&value), + None => Ok(probe_dir_ignore_case(dir)), + } +} + +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 { + 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); + }); + 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 { @@ -219,6 +315,8 @@ pub async fn guard_tree_case_collisions( #[cfg(test)] mod tests { + use serial_test::serial; + use super::*; #[test] @@ -232,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"]; @@ -270,4 +384,61 @@ 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") + )); + } + } + + #[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" + ); + } } diff --git a/tests/command/case_handling_test.rs b/tests/command/case_handling_test.rs index 06989cf58..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(); @@ -128,6 +183,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<String> = 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<String> = 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(); diff --git a/web/package.json b/web/package.json index a1e433abd..e4959e20b 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" }