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..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` 在写任何工作树/索引前失败,并新增纯添加 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 3ad939e38..a177c6579 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,恢复结果以未暂存工作树修改呈现;若当前 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/src/command/stash.rs b/src/command/stash.rs index 14943d19e..b19cae264 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, }, @@ -94,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), @@ -124,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, @@ -169,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}")), @@ -1307,8 +1313,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 = @@ -1353,15 +1360,14 @@ 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)?; 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 +1409,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(()) } @@ -2283,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] @@ -2330,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", @@ -2349,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 @@ -2408,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 4270a4af3..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; @@ -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,108 @@ 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"); +} + +#[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() {