From 6d65393d0e375943e0ecf189bbd447f7d9a9eeaa Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Mon, 6 Jul 2026 11:46:25 +0800 Subject: [PATCH 1/3] 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 9518a64151dec3ecc5e6349e75875e8519932d29 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Mon, 6 Jul 2026 13:00:13 +0800 Subject: [PATCH 2/3] fix(stash): preserve index on apply and pop Signed-off-by: Eli Ma --- COMPATIBILITY.md | 2 +- docs/commands/stash.md | 10 +++-- docs/development/commands/merge.md | 2 +- docs/development/commands/stash.md | 6 +-- src/command/stash.rs | 17 +++---- tests/command/stash_test.rs | 72 ++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 18 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 2eebc7453..70d72a82f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -50,7 +50,7 @@ batch document. | restore | partial | `--source`, `--staged`, `--worktree`, path restore, `--pathspec-from-file`/`--pathspec-file-nul`, `--overlay`/`--no-overlay` (real toggle, last wins — overlay only creates/updates source paths and never removes tracked paths absent from the source; default no-overlay removes them), and `--no-progress` (accepted no-op — Libra's restore renders no progress meter) are supported; conflict-stage restore `--ours`/`-2` and `--theirs`/`-3` write the chosen merge stage to the working tree only (index left unmerged), a plain restore over an unmerged path is refused (`LBR-CONFLICT-001`, exit 128), and `--ignore-unmerged` skips unmerged paths; a modify/delete conflict (the requested side deleted the file — its stage absent) removes the working-tree file and exits 0 in the default no-overlay mode (restoring a deletion means deleting; matches `git restore`), while `--overlay` instead errors `does not have our/their version` (exit 128); mid-rebase `--ours`/`--theirs` follow Git's swap (stages read verbatim, so `--ours` = the branch rebased onto / new base, `--theirs` = the commit being replayed), whereas merge/cherry-pick keep ours = HEAD / theirs = incoming; `--ours`/`--theirs` operate only on unmerged paths — a non-conflicted pathspec is skipped (or `PathspecNotMatched` when every pathspec is non-conflicted), and Libra intentionally does not fall through to Git's stage-0 (index) restore for such paths so a dirty file is never silently reverted; `--merge` (and `--conflict=merge|diff3`) rewrites the working tree for unmerged paths with the conflict markers rebuilt from the index stages — Libra's whole-file `ours`/`theirs` markers (generic `ours`/`theirs` labels — `diff3` also emits the base block; `zdiff3` is not supported; unlike `libra merge`/`cherry-pick`, which now write line-level markers, restore's index-stage rebuild stays whole-file), not Git's line-level 3-way; only the `--progress` meter is not exposed | | status | supported | common Git status surface plus `--porcelain` v1/v2, `-u`/`--untracked-files` (`no`/`normal`/`all`; bare = `all`, short attached values `-uno`/`-uall`/`-unormal`), `--short`, `--long` (explicit default; conflicts with `--short`/`--porcelain`), `--branch`/`-b`, `-z` NUL-terminated output, `--find-renames`, `--renames`/`--no-renames` (toggle rename detection; `--no-renames` wins), `--column`/`--no-column` (toggle column layout; `--no-column` countermands `--column`, last wins, and status is not columnar by default so `--no-column` alone is a no-op), and `--ahead-behind`/`--no-ahead-behind` supported; Libra dirty-cache extensions (lore.md 1.1, mutually exclusive, also conflict with `--porcelain`/`--short`/`--ignored`): `--scan` (normal full status + atomic cache rebuild, TOCTOU-guarded on the index fingerprint + HEAD, scan lock with stale-steal), `--cached` (consume the snapshot — O(dirty), no worktree walk, no HEAD-tree loads; any freshness doubt degrades to the full status with a hint; snapshot semantics: post-scan worktree-only edits are invisible until rescanned or marked via `libra dirty`; unrelated to Git's `--cached`), `--check-dirty` (re-verify only the cached set, pruning rows proven clean); default status JSON gains no keys — the modes add `mode`/`freshness`/`cache_state`/`cached_paths`(+`checked_paths`/`stale_paths`) | | clean | partial | `-n`, `-f`, `-d`, `-x`, `-X`, `-e`/`--exclude`, and `...` positional filtering are supported; `-i` is intentionally different/not exposed | -| stash | partial | `push` / `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` supported; `stash show` supports the file-level summary (`--name-only` / `--name-status`) and `-p`/`--patch` (unified diff of the stashed changes, via the shared diff engine). `stash push` supports `-m`, `-u` / `--include-untracked` (with `--no-include-untracked` to countermand it; last one wins, and untracked files are excluded by default so `--no-include-untracked` alone is a no-op), `-a` / `--all`, `-k`/`--keep-index`, and `-- ` (stash only the changes to the named paths/dirs — file or directory, `.` selects the whole tree — leaving the rest of the working tree intact; a pathspec matching nothing tracked is `LBR-CLI-003`; `-u`/`-a`/`-k` cannot be combined with a pathspec — `LBR-CLI-002`); included untracked/ignored files are stored in a third stash parent and restored by `apply` / `pop`. `apply` / `pop` perform the three-way restore against the CURRENT working tree (not HEAD), so unrelated uncommitted changes — including the paths a pathspec push left behind — are preserved. Deferred: `pop/apply --index`, `create`, and `store` (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) | +| stash | partial | `push` / `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` supported; `stash show` supports the file-level summary (`--name-only` / `--name-status`) and `-p`/`--patch` (unified diff of the stashed changes, via the shared diff engine). `stash push` supports `-m`, `-u` / `--include-untracked` (with `--no-include-untracked` to countermand it; last one wins, and untracked files are excluded by default so `--no-include-untracked` alone is a no-op), `-a` / `--all`, `-k`/`--keep-index`, and `-- ` (stash only the changes to the named paths/dirs — file or directory, `.` selects the whole tree — leaving the rest of the working tree intact; a pathspec matching nothing tracked is `LBR-CLI-003`; `-u`/`-a`/`-k` cannot be combined with a pathspec — `LBR-CLI-002`); included untracked/ignored files are stored in a third stash parent and restored by `apply` / `pop`. `apply` / `pop` perform the three-way restore against the CURRENT working tree (not HEAD), so unrelated uncommitted changes — including the paths a pathspec push left behind — are preserved; by default they leave the current index intact, so restored tracked changes appear as unstaged working-tree changes. Deferred: `pop/apply --index`, `create`, and `store` (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) | | lfs | partial | built-in Libra LFS command; uses `.libra_attributes`, not Git LFS filters/hooks (see [docs/development/commands/_compatibility.md#d5-git-lfs-gitattributes-filter--hooks-bridge](docs/development/commands/_compatibility.md#d5-git-lfs-gitattributes-filter--hooks-bridge)); `lfs.lockEnforce` (lore.md 2.8, Libra extension — git-lfs has no equivalent config): opt-in `off`(default)|`warn`|`block` gate on `add`/`commit` against locks held by OTHERS (server-side ours/theirs split via `POST locks/verify`; holding the lock is permission), covering staged new+modified+DELETED paths (deletions never reach the push-time OID check — this gate is their only guard); zero overhead when unset or no LFS-tracked path is staged; warn prints per-lock stderr warnings + records the warning (exit-code-on-warning contract) and proceeds; block aborts BEFORE any blob/index write on `add` (atomic; after `-a` auto-staging on `commit`, matching pre-commit-hook semantics) with LBR-CONFLICT-002 + hints; matrix: 404=no locking API→no-op (mirrors push), 403→warn proceeds/block fails AuthPermissionDenied, transport/5xx→warn proceeds/block FAILS CLOSED (LBR-NET-001 — an opted-in hard guarantee must not silently degrade; escape hatches named), explicit offline intent→skip+recorded warning BOTH modes (deletion residual documented), detached HEAD→skip+warning, no remote at all→structural no-op, fresh no-upstream branch does NOT skip (falls back to remote.origin.url); invalid value=hard usage error (never silently off); `--dry-run`/`--porcelain` previews never touch the network; push-time verification stays the authoritative backstop (TOCTOU acknowledged) | | ls-files | partial | default cached listing plus `--cached`/`-c`, `--deleted`/`-d`, `--modified`/`-m`, `--stage` / `-s`, `--abbrev[=]` (fixed-length object-name abbreviation in `-s`/`--stage` output; bare = 7), `--others`/`-o`, `--exclude-standard`, `-i`/`--ignored` (list only the ignored set — `-i -o` ignored untracked files, `-i -c` tracked files matching an exclude pattern; requires `-o`/`-c` and an exclude source — `--exclude-standard` or an explicit `-x`/`-X` pattern — else exit 128, matching Git), `...`, `--error-unmatch`, `-z` text output, status tags `-t` (H/R/C/?/M), unmerged-only `-u`/`--unmerged`, `--full-name` (accepted no-op; Libra always prints repo-root-relative paths), explicit exclude sources `-x`/`--exclude ` and `-X`/`--exclude-from ` (gitignore syntax; filter the `--others` listing and count toward the `-i` ignored set), `--eol` (prefix each cached entry with `i/ w/ attr/` line-ending info — `lf`/`crlf`/`mixed`/`none`/`-text` for the index blob and the worktree file, byte-compatible with `git ls-files --eol`; `attr/` is always empty as Libra has no `.gitattributes`), and JSON/machine output supported; pathspecs resolve from the caller's CWD and pathspecs outside the repo are rejected; resolve-undo and sparse-checkout integration are not exposed | | log | partial | common Git log surface plus the named `--pretty`/`--format` presets `oneline`/`short`/`full`/`fuller`/`reference`/`raw` (`medium` is the default), `--range` revision expressions, `--all`, `--reverse`, `--author-date-order` (sort by author date instead of committer date; timestamp-only, no topological constraint), `--date-order` (accepted no-op; selects the default committer-date order, conflicts with `--author-date-order`), `--no-expand-tabs` (accepted no-op; Libra never expands tabs in commit messages), `--no-notes` (accepted no-op; Libra's log never displays notes inline), `--no-mailmap` (accepted no-op; Libra's log never applies a mailmap), `--no-show-signature` (accepted no-op; Libra's log never displays commit signatures inline), `--follow`, `-L`, `--parents`/`--children` (append parent / in-range child ids after each commit hash), `-i`/`--regexp-ignore-case` + `--invert-grep` (case-insensitive / inverted `--grep`), `--patch-with-stat` (diffstat block then patch, Git's synonym for `-p --stat`; an explicit `-p --stat` likewise shows both), and positional revision-range syntax (`log A..B` / `A...B` / `^A` / a bare ``, followed by optional pathspecs; a bare name that is both a revision and a path is rejected as ambiguous — use `--range`) supported; the `--expand-tabs[=]` tab expansion, `--show-signature` signature display, and exact function-range tracking remain partial; Libra extensions (lore.md 1.9): `--trailer ` (repeatable AND filter over the commit's Git-qualifying trailer block — key ASCII case-insensitive, `=VALUE` exact on the unfolded value; Git has no such flag, nearest is a fragile `--grep='^Key: '`) and `--only-trailers` (show only each commit's trailer block, key-filtered when combined with `--trailer`; nearest Git equivalent `--pretty='%(trailers)'`, whose placeholder is not yet implemented); `--json log` gains an additive `trailers: [{key,value}]` field (empty array when none; `body` unchanged) | diff --git a/docs/commands/stash.md b/docs/commands/stash.md index 8ef2fef13..bbef62070 100644 --- a/docs/commands/stash.md +++ b/docs/commands/stash.md @@ -17,7 +17,7 @@ libra stash clear [--force] ## Description -`libra stash` saves your local modifications to a new stash entry and reverts the working directory to match HEAD. By default, `stash push` records tracked index/worktree changes and leaves untracked files alone. Use `-u` / `--include-untracked` to include visible untracked files, or `-a` / `--all` to include ignored files too. Pass `-- ...` (file or directory paths; `.` selects the whole tree) to stash only the changes to those paths, leaving every other change in the working tree. A pathspec cannot be combined with `-u`/`-a`/`-k`. The modifications can be restored later with `libra stash pop` or `libra stash apply`, which replay the stash onto the CURRENT working tree (not HEAD) — so any unrelated uncommitted change you made in the meantime, including the paths a pathspec push left behind, is preserved. If `stash push` is run on a clean working tree and no requested untracked files exist, it exits successfully as a no-op and reports that there are no local changes to save. +`libra stash` saves your local modifications to a new stash entry and reverts the working directory to match HEAD. By default, `stash push` records tracked index/worktree changes and leaves untracked files alone. Use `-u` / `--include-untracked` to include visible untracked files, or `-a` / `--all` to include ignored files too. Pass `-- ...` (file or directory paths; `.` selects the whole tree) to stash only the changes to those paths, leaving every other change in the working tree. A pathspec cannot be combined with `-u`/`-a`/`-k`. The modifications can be restored later with `libra stash pop` or `libra stash apply`, which replay the stash onto the CURRENT working tree (not HEAD) — so any unrelated uncommitted change you made in the meantime, including the paths a pathspec push left behind, is preserved. Default `apply` / `pop` leave the current index intact, so restored tracked changes appear as unstaged working-tree changes; Git's `--index` restore mode is not exposed yet. If `stash push` is run on a clean working tree and no requested untracked files exist, it exits successfully as a no-op and reports that there are no local changes to save. Stash entries are stored as specially-structured commit objects under `.libra/refs/stash`, with a flat-file list tracking the stash stack. Each stash captures both the index state and worktree state at the time of creation. @@ -60,7 +60,7 @@ libra stash push -- src/main.rs docs/ #### `pop` -Apply the top stash entry and remove it from the stash list. Equivalent to `apply` followed by `drop`. +Apply the top stash entry and remove it from the stash list. Equivalent to `apply` followed by `drop`. By default, restored tracked changes are written to the working tree only and are not staged. | Argument | Description | |----------|-------------| @@ -84,7 +84,7 @@ libra stash list #### `apply` -Apply a stash entry without removing it from the stash list. Useful when you want to apply the same stash to multiple branches. +Apply a stash entry without removing it from the stash list. Useful when you want to apply the same stash to multiple branches. By default, restored tracked changes are written to the working tree only and are not staged. | Argument | Description | |----------|-------------| @@ -380,6 +380,10 @@ The structured envelope always emits the full `files` list. The `--name-only` / `stash push -u` and `stash push -a` use a third stash parent for the untracked/all snapshot, matching Git's object topology. `stash apply` and `stash pop` restore those files as untracked worktree files. If a local file would be overwritten during restore, the apply/pop operation fails and keeps the stash entry intact. +### How `pop` / `apply` treat the index + +Default `stash pop` and `stash apply` restore tracked content to the working tree while leaving the current index unchanged. A change that was staged when stashed comes back as an unstaged working-tree edit unless a future `--index` mode is added. This matches Git's default `stash pop` / `stash apply` behavior and prevents a later `libra commit` from committing restored stash content before the user runs `libra add`. + ### How `--keep-index` works `stash push --keep-index` stores the same stash metadata as a normal push, then writes the saved index back and restores the worktree to the index state. For a mixed file with both staged and unstaged edits, the staged content remains in the index and worktree, while the unstaged delta is saved in the stash. diff --git a/docs/development/commands/merge.md b/docs/development/commands/merge.md index 2fc9a9de5..2c8527817 100644 --- a/docs/development/commands/merge.md +++ b/docs/development/commands/merge.md @@ -35,7 +35,7 @@ flowchart TD ### --autostash(lore.md 1.8) -- 合并属主状态机(非 pull 的 push/pop 包裹):`stash::push_held_stash`(tracked-only,写 stash 提交对象但**不入 refs/stash**)→ sidecar `merge-autostash.json`(原子+fsync,MERGE_AUTOSTASH 模拟;held 提交仅由此文件可达——**未来 GC/prune 必须视其为根**)→ 硬重置 → 合并。统一 finalize(每个动作后运行):无 merge-state → 回贴(干净→删 sidecar;冲突→`store_stash_commit` 提升入 stash list + 通知,回贴 all-or-nothing——`apply_stash_commit` 在写任何工作树/索引前失败,并新增纯添加 vs 未跟踪文件碰撞守卫;其它错误→警告+保留 sidecar,合并结果不变);有 merge-state → held。`--restart` 以 `preserve_held_autostash` 跳过陈旧回收(否则 held stash 会被误降级)。陈旧 sidecar(崩溃残留)在下次合并启动时提升入 stash list(警告注明可能与已回贴内容重复)。配置 `merge.autostash` git-bool,非法值硬错误;`--dry-run` 下配置启用被静默抑制(dry-run 零写入契约)。已知限制(文档化):与 stash pop 同源的 index/worktree 分离不保真(staged-only 且工作树回退的内容会丢——继承 stash 行为);`libra commit` 不终结合并(Libra 需 `merge --continue`,故 sidecar 不会被普通 commit 触发回贴);并发合并进程间 sidecar 无锁(与 merge-state.json 同级暴露)。 +- 合并属主状态机(非 pull 的 push/pop 包裹):`stash::push_held_stash`(tracked-only,写 stash 提交对象但**不入 refs/stash**)→ sidecar `merge-autostash.json`(原子+fsync,MERGE_AUTOSTASH 模拟;held 提交仅由此文件可达——**未来 GC/prune 必须视其为根**)→ 硬重置 → 合并。统一 finalize(每个动作后运行):无 merge-state → 回贴(干净→删 sidecar;冲突→`store_stash_commit` 提升入 stash list + 通知,回贴 all-or-nothing——`apply_stash_commit` 在写工作树前完成冲突/碰撞预检,且默认不重建 index,并新增纯添加 vs 未跟踪文件碰撞守卫;其它错误→警告+保留 sidecar,合并结果不变);有 merge-state → held。`--restart` 以 `preserve_held_autostash` 跳过陈旧回收(否则 held stash 会被误降级)。陈旧 sidecar(崩溃残留)在下次合并启动时提升入 stash list(警告注明可能与已回贴内容重复)。配置 `merge.autostash` git-bool,非法值硬错误;`--dry-run` 下配置启用被静默抑制(dry-run 零写入契约)。已知限制(文档化):与 stash pop 同源的 index/worktree 分离不保真(staged-only 且工作树回退的内容会丢——继承 stash 行为);`libra commit` 不终结合并(Libra 需 `merge --continue`,故 sidecar 不会被普通 commit 触发回贴);并发合并进程间 sidecar 无锁(与 merge-state.json 同级暴露)。 ## 实现历史 diff --git a/docs/development/commands/stash.md b/docs/development/commands/stash.md index 3ad939e38..31a1445b7 100644 --- a/docs/development/commands/stash.md +++ b/docs/development/commands/stash.md @@ -6,7 +6,7 @@ ## 对比 Git 与兼容性 -- 兼容级别:`partial`。`push`(含 `-- ` 部分 stash)/ `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` supported;`apply`/`pop` 三方合并到当前工作树(非 HEAD),保留无关未提交改动;`create` / `store` deferred (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) +- 兼容级别:`partial`。`push`(含 `-- ` 部分 stash)/ `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` supported;`apply`/`pop` 三方合并到当前工作树(非 HEAD),保留无关未提交改动,且默认只写工作树、不重建 index(恢复出的 tracked 修改保持未暂存,Git 的 `--index` 恢复模式仍未公开);`create` / `store` deferred (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) - 当前矩阵明确仍是部分兼容;未覆盖的 Git surface 必须显式列在“还未实现的功能”。 @@ -55,9 +55,9 @@ flowchart TD | 类别 | 未完成项 | 当前处理 | |---|---|---| -| 兼容矩阵说明 | `push` / `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` 支持;`push` 支持 `-m`、`-u` / `--include-untracked`、`-a` / `--all`、`-k` / `--keep-index`、`-- `(部分 stash);`apply`/`pop` 合并到当前工作树保留无关改动;`create` / `store` 延后 (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) | 按当前兼容矩阵保留;实现状态变化时同步 `_compatibility.md` 和测试证据。 | +| 兼容矩阵说明 | `push` / `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` 支持;`push` 支持 `-m`、`-u` / `--include-untracked`、`-a` / `--all`、`-k` / `--keep-index`、`-- `(部分 stash);`apply`/`pop` 合并到当前工作树保留无关改动,并默认保留当前 index,让恢复出的 tracked 修改保持未暂存;`pop/apply --index`、`create` / `store` 延后 (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) | 按当前兼容矩阵保留;实现状态变化时同步 `_compatibility.md` 和测试证据。 | | ✅ 已实现 | Patch 级差异 (stash show) | `stash show -p` / `--patch` 复用 `log::generate_diff(&stash_commit, …)`(stash commit 第一父即 base,等价于 `git stash show -p`)输出统一 diff;人类格式仅打印 diff(无摘要脚注),JSON 加项 `patch`(无 `-p` 时省略,向后兼容)。带集成测试 `test_stash_show_patch_emits_unified_diff`。 | -| ✅ 已实现 | Pathspec(部分 stash) | 原始对照:Git `stash push -- `;当前说明:`run_push_pathspec` 把 stash 树构造为「HEAD 叠加命名路径的索引/工作区内容」(经 `merge::create_tree_from_items_map` 建嵌套树),记录后只把命名路径重置回 HEAD,其余工作树原样保留;无匹配→`PathspecNoMatch`/`LBR-CLI-003`。pop 侧由 do_apply 的「ours=当前工作树」修复保证不冲掉无关改动。pathspec 匹配支持精确路径、目录前缀与 `.`(整树),经 `util::to_workdir_path` 归一化(支持子目录相对 pathspec);`-u`/`-a`/`-k` 与 pathspec 同用被拒(`PathspecWithOption`/`LBR-CLI-002`)。**staged-only 改动**:因 Libra 无 `stash apply --index`,pathspec push 的 worktree 树按「有未暂存改动取工作区、否则取暂存内容、否则 HEAD」折叠,故 pop 能恢复 staged-only 选择(与 git pop-without-index 丢弃不同,Libra 不丢)。带集成测试 `test_stash_push_pathspec_stashes_only_matched`/`_directory`/`_no_match_errors`/`test_stash_pop_preserves_unrelated_uncommitted_change`。 | +| ✅ 已实现 | Pathspec(部分 stash) | 原始对照:Git `stash push -- `;当前说明:`run_push_pathspec` 把 stash 树构造为「HEAD 叠加命名路径的索引/工作区内容」(经 `merge::create_tree_from_items_map` 建嵌套树),记录后只把命名路径重置回 HEAD,其余工作树原样保留;无匹配→`PathspecNoMatch`/`LBR-CLI-003`。pop 侧由 do_apply 的「ours=当前工作树」修复保证不冲掉无关改动,且默认不重建 index,恢复结果以未暂存工作树修改呈现。pathspec 匹配支持精确路径、目录前缀与 `.`(整树),经 `util::to_workdir_path` 归一化(支持子目录相对 pathspec);`-u`/`-a`/`-k` 与 pathspec 同用被拒(`PathspecWithOption`/`LBR-CLI-002`)。**staged-only 改动**:因 Libra 无 `stash apply --index`,pathspec push 的 worktree 树按「有未暂存改动取工作区、否则取暂存内容、否则 HEAD」折叠,故 pop 能恢复 staged-only 选择为未暂存工作树内容(与 git pop-without-index 丢弃不同,Libra 不丢)。带集成测试 `test_stash_push_pathspec_stashes_only_matched`/`_directory`/`_no_match_errors`/`test_stash_pop_preserves_unrelated_uncommitted_change`/`test_stash_pop_restores_unstaged_change_without_staging`/`test_stash_pop_restores_staged_only_change_as_unstaged_by_default`/`test_stash_pop_restores_mixed_file_as_unstaged_worktree_content`。 | | 兼容差异项 | Plumbing create/store | 原始对照:不支持 (延后 — see compatibility/declined.md D8/D9);相关参数/替代:stash create / stash store;当前说明:不适用。 后续实现时需要补对应回归测试并同步兼容矩阵。 | ## 维护要求 diff --git a/src/command/stash.rs b/src/command/stash.rs index 14943d19e..f4ed96481 100644 --- a/src/command/stash.rs +++ b/src/command/stash.rs @@ -34,8 +34,7 @@ use crate::{ load_object, log, merge::{MergeTreeEntry, create_tree_from_items_map}, reset::{ - rebuild_index_from_tree, remove_empty_directories, reset_index_to_commit, - restore_working_directory_from_tree, + remove_empty_directories, reset_index_to_commit, restore_working_directory_from_tree, }, status, }, @@ -1307,8 +1306,9 @@ async fn do_apply(stash: Option) -> Result { /// Apply a stash COMMIT by OID — the three-way apply shared by /// `stash apply/pop` and the merge autostash finalizer (which holds a stash /// commit reachable only from its sidecar, never from refs/stash). All-or- -/// nothing: any conflict or collision fails BEFORE the worktree or index is -/// touched, leaving the current state intact. +/// nothing for the working tree: any conflict or collision fails BEFORE files +/// are rewritten, leaving the current state intact. The current index is +/// intentionally preserved by default. pub(crate) async fn apply_stash_commit(hash: &ObjectHash) -> Result<(), StashError> { let stash_commit_hash = *hash; let git_dir = @@ -1360,8 +1360,6 @@ pub(crate) async fn apply_stash_commit(hash: &ObjectHash) -> Result<(), StashErr let merged_tree = merge_trees(&base_tree, &worktree_tree, &stash_tree, &git_dir) .map_err(StashError::MergeConflict)?; - let mut new_index = Index::new(); - let worktree_files = tree::get_tree_files_recursive(&worktree_tree, &git_dir, &PathBuf::new()) .map_err(|e| StashError::ReadObject(e.to_string()))?; let merged_files = tree::get_tree_files_recursive(&merged_tree, &git_dir, &PathBuf::new()) @@ -1403,15 +1401,14 @@ pub(crate) async fn apply_stash_commit(hash: &ObjectHash) -> Result<(), StashErr restore_working_directory_from_tree(&merged_tree, workdir, "") .map_err(StashError::WriteObject)?; - rebuild_index_from_tree(&merged_tree, &mut new_index, "").map_err(StashError::IndexSave)?; if let Some(untracked_tree) = untracked_tree.as_ref() { restore_working_directory_from_tree(untracked_tree, workdir, "") .map_err(StashError::WriteObject)?; } - new_index - .save(&index_path) - .map_err(|e| StashError::IndexSave(e.to_string()))?; + // Git's default `stash apply/pop` restores changes to the working tree only. + // Keep the existing index intact; a future `--index` mode should restore the + // stash's second parent explicitly instead of rebuilding from `merged_tree`. Ok(()) } diff --git a/tests/command/stash_test.rs b/tests/command/stash_test.rs index 4270a4af3..ec8d8ba6b 100644 --- a/tests/command/stash_test.rs +++ b/tests/command/stash_test.rs @@ -26,6 +26,12 @@ fn latest_stash_commit(repo: &Path) -> Commit { load_object::(&stash_hash).expect("failed to load latest stash commit") } +fn status_short(repo: &Path) -> String { + let output = run_libra_command(&["status", "--short"], repo); + assert_cli_success(&output, "status --short"); + String::from_utf8(output.stdout).expect("status --short output should be UTF-8") +} + #[test] #[serial] fn test_stash_cli_outside_repository_returns_fatal_128() { @@ -305,6 +311,72 @@ async fn test_stash_push_and_pop_preserves_dotfiles() { ); } +#[test] +fn test_stash_pop_restores_unstaged_change_without_staging() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + + // Given: a tracked file has only a working-tree edit. + fs::write(p.join("tracked.txt"), "worktree version\n").unwrap(); + assert_cli_success(&run_libra_command(&["stash", "push"], p), "stash push"); + + // When: the stash is popped without --index support. + assert_cli_success(&run_libra_command(&["stash", "pop"], p), "stash pop"); + + // Then: the edit is back in the working tree but remains unstaged, matching + // Git's default `stash pop` behavior. + assert_eq!(status_short(p), " M tracked.txt\n"); +} + +#[test] +fn test_stash_pop_restores_staged_only_change_as_unstaged_by_default() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + + // Given: a tracked file has only a staged edit. + fs::write(p.join("tracked.txt"), "staged version\n").unwrap(); + assert_cli_success( + &run_libra_command(&["add", "tracked.txt"], p), + "stage tracked file", + ); + assert_cli_success(&run_libra_command(&["stash", "push"], p), "stash push"); + + // When: the stash is popped without --index support. + assert_cli_success(&run_libra_command(&["stash", "pop"], p), "stash pop"); + + // Then: default pop restores the content as an unstaged working-tree edit. + assert_eq!( + fs::read_to_string(p.join("tracked.txt")).unwrap(), + "staged version\n" + ); + assert_eq!(status_short(p), " M tracked.txt\n"); +} + +#[test] +fn test_stash_pop_restores_mixed_file_as_unstaged_worktree_content() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + + // Given: a file has both staged content and a newer working-tree edit. + fs::write(p.join("tracked.txt"), "staged version\n").unwrap(); + assert_cli_success( + &run_libra_command(&["add", "tracked.txt"], p), + "stage tracked file", + ); + fs::write(p.join("tracked.txt"), "worktree version\n").unwrap(); + assert_cli_success(&run_libra_command(&["stash", "push"], p), "stash push"); + + // When: the stash is popped without --index support. + assert_cli_success(&run_libra_command(&["stash", "pop"], p), "stash pop"); + + // Then: the working-tree content wins, but the index remains at HEAD. + assert_eq!( + fs::read_to_string(p.join("tracked.txt")).unwrap(), + "worktree version\n" + ); + assert_eq!(status_short(p), " M tracked.txt\n"); +} + #[tokio::test] #[serial] async fn test_stash_list() { From 33cf5362e049670a06865d8aae1336fcf39d90e4 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Mon, 6 Jul 2026 13:19:28 +0800 Subject: [PATCH 3/3] fix(stash): address apply index review Signed-off-by: Eli Ma --- docs/development/commands/merge.md | 2 +- docs/development/commands/stash.md | 2 +- docs/development/tracing/sandbox.md | 99 +---------------------------- src/command/stash.rs | 24 +++++-- tests/command/stash_test.rs | 38 ++++++++++- web/package.json | 2 +- 6 files changed, 61 insertions(+), 106 deletions(-) diff --git a/docs/development/commands/merge.md b/docs/development/commands/merge.md index 2c8527817..439af70ca 100644 --- a/docs/development/commands/merge.md +++ b/docs/development/commands/merge.md @@ -35,7 +35,7 @@ flowchart TD ### --autostash(lore.md 1.8) -- 合并属主状态机(非 pull 的 push/pop 包裹):`stash::push_held_stash`(tracked-only,写 stash 提交对象但**不入 refs/stash**)→ sidecar `merge-autostash.json`(原子+fsync,MERGE_AUTOSTASH 模拟;held 提交仅由此文件可达——**未来 GC/prune 必须视其为根**)→ 硬重置 → 合并。统一 finalize(每个动作后运行):无 merge-state → 回贴(干净→删 sidecar;冲突→`store_stash_commit` 提升入 stash list + 通知,回贴 all-or-nothing——`apply_stash_commit` 在写工作树前完成冲突/碰撞预检,且默认不重建 index,并新增纯添加 vs 未跟踪文件碰撞守卫;其它错误→警告+保留 sidecar,合并结果不变);有 merge-state → held。`--restart` 以 `preserve_held_autostash` 跳过陈旧回收(否则 held stash 会被误降级)。陈旧 sidecar(崩溃残留)在下次合并启动时提升入 stash list(警告注明可能与已回贴内容重复)。配置 `merge.autostash` git-bool,非法值硬错误;`--dry-run` 下配置启用被静默抑制(dry-run 零写入契约)。已知限制(文档化):与 stash pop 同源的 index/worktree 分离不保真(staged-only 且工作树回退的内容会丢——继承 stash 行为);`libra commit` 不终结合并(Libra 需 `merge --continue`,故 sidecar 不会被普通 commit 触发回贴);并发合并进程间 sidecar 无锁(与 merge-state.json 同级暴露)。 +- 合并属主状态机(非 pull 的 push/pop 包裹):`stash::push_held_stash`(tracked-only,写 stash 提交对象但**不入 refs/stash**)→ sidecar `merge-autostash.json`(原子+fsync,MERGE_AUTOSTASH 模拟;held 提交仅由此文件可达——**未来 GC/prune 必须视其为根**)→ 硬重置 → 合并。统一 finalize(每个动作后运行):无 merge-state → 回贴(干净→删 sidecar;冲突→`store_stash_commit` 提升入 stash list + 通知,回贴 all-or-nothing——`apply_stash_commit` 在写工作树前完成冲突/碰撞预检,且默认不重建 index,并新增纯添加 vs 未跟踪文件碰撞守卫;其它错误→警告+保留 sidecar,合并结果不变);有 merge-state → held。`--restart` 以 `preserve_held_autostash` 跳过陈旧回收(否则 held stash 会被误降级)。陈旧 sidecar(崩溃残留)在下次合并启动时提升入 stash list(警告注明可能与已回贴内容重复)。配置 `merge.autostash` git-bool,非法值硬错误;`--dry-run` 下配置启用被静默抑制(dry-run 零写入契约)。已知限制(文档化):与 stash pop 同源的 index/worktree 分离不保真(staged-only 或混合修改回贴后内容会回到工作树,但原 staged 状态不会保留,会表现为未暂存修改);`libra commit` 不终结合并(Libra 需 `merge --continue`,故 sidecar 不会被普通 commit 触发回贴);并发合并进程间 sidecar 无锁(与 merge-state.json 同级暴露)。 ## 实现历史 diff --git a/docs/development/commands/stash.md b/docs/development/commands/stash.md index 31a1445b7..a177c6579 100644 --- a/docs/development/commands/stash.md +++ b/docs/development/commands/stash.md @@ -57,7 +57,7 @@ flowchart TD |---|---|---| | 兼容矩阵说明 | `push` / `pop` / `list` / `apply` / `drop` / `show` / `branch` / `clear` 支持;`push` 支持 `-m`、`-u` / `--include-untracked`、`-a` / `--all`、`-k` / `--keep-index`、`-- `(部分 stash);`apply`/`pop` 合并到当前工作树保留无关改动,并默认保留当前 index,让恢复出的 tracked 修改保持未暂存;`pop/apply --index`、`create` / `store` 延后 (see [docs/development/commands/_compatibility.md#d8-stash-create](docs/development/commands/_compatibility.md#d8-stash-create) and [#d9-stash-store](docs/development/commands/_compatibility.md#d9-stash-store)) | 按当前兼容矩阵保留;实现状态变化时同步 `_compatibility.md` 和测试证据。 | | ✅ 已实现 | Patch 级差异 (stash show) | `stash show -p` / `--patch` 复用 `log::generate_diff(&stash_commit, …)`(stash commit 第一父即 base,等价于 `git stash show -p`)输出统一 diff;人类格式仅打印 diff(无摘要脚注),JSON 加项 `patch`(无 `-p` 时省略,向后兼容)。带集成测试 `test_stash_show_patch_emits_unified_diff`。 | -| ✅ 已实现 | Pathspec(部分 stash) | 原始对照:Git `stash push -- `;当前说明:`run_push_pathspec` 把 stash 树构造为「HEAD 叠加命名路径的索引/工作区内容」(经 `merge::create_tree_from_items_map` 建嵌套树),记录后只把命名路径重置回 HEAD,其余工作树原样保留;无匹配→`PathspecNoMatch`/`LBR-CLI-003`。pop 侧由 do_apply 的「ours=当前工作树」修复保证不冲掉无关改动,且默认不重建 index,恢复结果以未暂存工作树修改呈现。pathspec 匹配支持精确路径、目录前缀与 `.`(整树),经 `util::to_workdir_path` 归一化(支持子目录相对 pathspec);`-u`/`-a`/`-k` 与 pathspec 同用被拒(`PathspecWithOption`/`LBR-CLI-002`)。**staged-only 改动**:因 Libra 无 `stash apply --index`,pathspec push 的 worktree 树按「有未暂存改动取工作区、否则取暂存内容、否则 HEAD」折叠,故 pop 能恢复 staged-only 选择为未暂存工作树内容(与 git pop-without-index 丢弃不同,Libra 不丢)。带集成测试 `test_stash_push_pathspec_stashes_only_matched`/`_directory`/`_no_match_errors`/`test_stash_pop_preserves_unrelated_uncommitted_change`/`test_stash_pop_restores_unstaged_change_without_staging`/`test_stash_pop_restores_staged_only_change_as_unstaged_by_default`/`test_stash_pop_restores_mixed_file_as_unstaged_worktree_content`。 | +| ✅ 已实现 | Pathspec(部分 stash) | 原始对照:Git `stash push -- `;当前说明:`run_push_pathspec` 把 stash 树构造为「HEAD 叠加命名路径的索引/工作区内容」(经 `merge::create_tree_from_items_map` 建嵌套树),记录后只把命名路径重置回 HEAD,其余工作树原样保留;无匹配→`PathspecNoMatch`/`LBR-CLI-003`。pop 侧由 do_apply 的「ours=当前工作树」修复保证不冲掉无关改动,且默认不重建 index,恢复结果以未暂存工作树修改呈现;若当前 index 无法读取则显式报错并保留 stash。pathspec 匹配支持精确路径、目录前缀与 `.`(整树),经 `util::to_workdir_path` 归一化(支持子目录相对 pathspec);`-u`/`-a`/`-k` 与 pathspec 同用被拒(`PathspecWithOption`/`LBR-CLI-002`)。**staged-only 改动**:因 Libra 无 `stash apply --index`,pathspec push 的 worktree 树按「有未暂存改动取工作区、否则取暂存内容、否则 HEAD」折叠,故 pop 能恢复 staged-only 选择为未暂存工作树内容(与 git pop-without-index 丢弃不同,Libra 不丢)。带集成测试 `test_stash_push_pathspec_stashes_only_matched`/`_directory`/`_no_match_errors`/`test_stash_pop_preserves_unrelated_uncommitted_change`/`test_stash_pop_restores_unstaged_change_without_staging`/`test_stash_pop_restores_staged_only_change_as_unstaged_by_default`/`test_stash_pop_restores_mixed_file_as_unstaged_worktree_content`/`test_stash_pop_reports_index_load_failure_without_dropping_stash`。 | | 兼容差异项 | Plumbing create/store | 原始对照:不支持 (延后 — see compatibility/declined.md D8/D9);相关参数/替代:stash create / stash store;当前说明:不适用。 后续实现时需要补对应回归测试并同步兼容矩阵。 | ## 维护要求 diff --git a/docs/development/tracing/sandbox.md b/docs/development/tracing/sandbox.md index 99d14256d..af70be420 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) 目标;ricccrd/dd `9b950a2`(2026-07-04,`https://github.com/ricccrd/dd`) +> 调研基线:apple/container `1.0.0`、apple/containerization(pre-1.0),均为 Apple Silicon + macOS 26 (Tahoe) 目标 --- @@ -24,8 +24,6 @@ 整个改动**复用现有 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. 目标与范围 @@ -739,100 +737,6 @@ 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. 里程碑、测试矩阵与验收 @@ -872,7 +776,6 @@ untrusted automatic backend: apple-container only - 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/stash.rs b/src/command/stash.rs index f4ed96481..b19cae264 100644 --- a/src/command/stash.rs +++ b/src/command/stash.rs @@ -93,6 +93,9 @@ pub(crate) enum StashError { #[error("failed to write object: {0}")] WriteObject(String), + #[error("failed to load index: {0}")] + IndexLoad(String), + #[error("failed to save index: {0}")] IndexSave(String), @@ -123,6 +126,7 @@ impl StashError { Self::ClearRequiresForce => StableErrorCode::CliInvalidArguments, Self::ReadObject(_) => StableErrorCode::IoReadFailed, Self::WriteObject(_) => StableErrorCode::IoWriteFailed, + Self::IndexLoad(_) => StableErrorCode::IoReadFailed, Self::IndexSave(_) => StableErrorCode::IoWriteFailed, Self::ResetFailed(_) => StableErrorCode::IoWriteFailed, Self::PathspecNoMatch(_) => StableErrorCode::CliInvalidTarget, @@ -168,6 +172,9 @@ impl From for CliError { StashError::PathspecWithOption(_) => CliError::command_usage(message) .with_stable_code(stable_code) .with_hint("run the option without a pathspec, or the pathspec without the option"), + StashError::IndexLoad(_) => CliError::fatal(message) + .with_stable_code(stable_code) + .with_hint("repair or refresh the index, then retry the stash operation."), StashError::Other(_) => CliError::fatal(message) .with_stable_code(stable_code) .with_hint(format!("this is a bug; please report it at {ISSUE_URL}")), @@ -1353,7 +1360,8 @@ pub(crate) async fn apply_stash_commit(hash: &ObjectHash) -> Result<(), StashErr // base = the commit the stash was created on; theirs = the stashed tree. // `create_tree_from_workdir` writes every blob/subtree it visits, so the // resulting tree is fully materialised for `merge_trees`. - let current_index = Index::load(&index_path).unwrap_or_else(|_| Index::new()); + let current_index = Index::load(&index_path) + .map_err(|e| StashError::IndexLoad(format!("{}: {e}", index_path.display())))?; let worktree_tree = create_tree_from_workdir(workdir, &git_dir, ¤t_index) .map_err(StashError::ReadObject)?; @@ -2280,7 +2288,7 @@ mod tests { /// in both human and `--json` envelopes for `stash`. /// /// Source-chained variants whose body is solely a wrapped string - /// (ReadObject, WriteObject, IndexSave, ResetFailed, Other) are + /// (ReadObject, WriteObject, IndexLoad, IndexSave, ResetFailed, Other) are /// covered indirectly by pinning the inner `{0}` echo form here for /// representative cases (Other does that explicitly). #[test] @@ -2327,6 +2335,10 @@ mod tests { StashError::WriteObject("disk full".to_string()).to_string(), "failed to write object: disk full", ); + assert_eq!( + StashError::IndexLoad("corrupt".to_string()).to_string(), + "failed to load index: corrupt", + ); assert_eq!( StashError::IndexSave("io error".to_string()).to_string(), "failed to save index: io error", @@ -2346,8 +2358,8 @@ mod tests { /// [`StashError`]. JSON consumers branch on the /// [`StableErrorCode`] in the error envelope; three variants /// share `IoWriteFailed` (WriteObject / IndexSave / ResetFailed) - /// and two share both `IoReadFailed` (BranchLookupFailed / - /// ReadObject) and `CliInvalidArguments` (InvalidStashRef / + /// and three share `IoReadFailed` (BranchLookupFailed / + /// ReadObject / IndexLoad), while two share `CliInvalidArguments` (InvalidStashRef / /// ClearRequiresForce). A future refactor that reroutes any /// variant — for example flipping `BranchExists` from /// `ConflictOperationBlocked` to `CliInvalidTarget` — silently @@ -2405,6 +2417,10 @@ mod tests { StashError::WriteObject("ignored".to_string()).stable_code(), StableErrorCode::IoWriteFailed, ); + assert_eq!( + StashError::IndexLoad("ignored".to_string()).stable_code(), + StableErrorCode::IoReadFailed, + ); assert_eq!( StashError::IndexSave("ignored".to_string()).stable_code(), StableErrorCode::IoWriteFailed, diff --git a/tests/command/stash_test.rs b/tests/command/stash_test.rs index ec8d8ba6b..55790f02a 100644 --- a/tests/command/stash_test.rs +++ b/tests/command/stash_test.rs @@ -10,7 +10,7 @@ use libra::{ commit::{self, CommitArgs}, }, internal::branch::Branch, - utils::test::ChangeDirGuard, + utils::{error::StableErrorCode, test::ChangeDirGuard}, }; use serial_test::serial; use tempfile::tempdir; @@ -377,6 +377,42 @@ fn test_stash_pop_restores_mixed_file_as_unstaged_worktree_content() { assert_eq!(status_short(p), " M tracked.txt\n"); } +#[test] +fn test_stash_pop_reports_index_load_failure_without_dropping_stash() { + let repo = create_committed_repo_via_cli(); + let p = repo.path(); + + // Given: a valid stash exists, then the on-disk index becomes unreadable. + fs::write(p.join("tracked.txt"), "worktree version\n").unwrap(); + assert_cli_success(&run_libra_command(&["stash", "push"], p), "stash push"); + fs::write(p.join(".libra").join("index"), b"garb").unwrap(); + + // When: default pop tries to build the current-worktree side of the merge. + let output = run_libra_command(&["stash", "pop"], p); + + // Then: the index load failure is reported instead of treating the index as + // empty, and pop leaves the stash entry in place. + assert_eq!(output.status.code(), Some(128)); + let (human, report) = parse_cli_error_stderr(&output.stderr); + assert!( + human.contains("failed to load index"), + "unexpected human stderr: {human}" + ); + assert_eq!(report.error_code, StableErrorCode::IoReadFailed.as_str()); + assert!( + report.message.contains("failed to load index"), + "unexpected JSON message: {}", + report.message + ); + + let list = run_libra_command(&["stash", "list"], p); + assert_cli_success(&list, "stash list after failed pop"); + assert!( + String::from_utf8_lossy(&list.stdout).contains("stash@{0}:"), + "failed pop must keep the stash entry" + ); +} + #[tokio::test] #[serial] async fn test_stash_list() { diff --git a/web/package.json b/web/package.json index 718560ac5..7bd6f75fd 100644 --- a/web/package.json +++ b/web/package.json @@ -33,7 +33,7 @@ "typescript": "^5.9.3", "vitest": "^4.1.6" }, - "packageManager": "pnpm@11.10.0", + "packageManager": "pnpm@11.1.0", "engines": { "node": ">=22.13.0 <23 || >=23.4.0" }