From d109ec044fbf00a322ce41bbfdfb9db53122d718 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Wed, 23 Sep 2026 09:10:24 +0800 Subject: [PATCH 1/2] feat(bundle): advertise HEAD on create --all and explicit HEAD bundle create --all and an explicit HEAD revision now write a HEAD line in the v2 header so Git and Libra can check out the tip, including detached HEAD. Signed-off-by: Eli Ma --- COMPATIBILITY.md | 2 +- docs/commands/bundle.md | 12 +- docs/commands/zh-CN/bundle.md | 6 +- docs/development/plan/issues/474.md | 18 +-- docs/development/plan/plan-status.md | 9 +- src/command/bundle.rs | 60 +++++++--- tests/command/bundle_test.rs | 160 ++++++++++++++++++++++++++- 7 files changed, 223 insertions(+), 44 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 306edda3c..53c4cde77 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -200,7 +200,7 @@ there is no promised all-phase 30-second hard deadline. See | symbolic-ref | partial | Supports local `HEAD` only; other symbolic refs are rejected because Libra stores refs in SQLite | | merge | supported | This addendum supersedes older merge-message wording in the table: `merge` supports `-m`/`--message`, mutually exclusive `-F`/`--file`, `--into-name ` for generated destination text, opt-in `-e`/`--edit` with the shared editor cascade, `--no-edit` as the default and mutual exclusion, plus `--cleanup=strip|whitespace|verbatim|scissors|default`. The resolved message persists through non-squash conflict and `--no-commit` state for `--continue`; `--continue -F` and `--continue --edit` may replace it. Empty edited messages are refused before state or commit writes, and cleanup precedes `--signoff`. | | branch | partial | create/list/delete/rename/upstream set+unset/current/contains filters, `--points-at` (annotated tag/full tag refs recursively peel to the target commit), `--merged`/`--no-merged`, `--sort` (`refname`/`version:refname`/`committerdate`/`creatordate`/`authordate` — date keys sort by the tip commit's committer date, or its author date for `authordate` — and `objectsize` (the tip object's byte size) and `objectname` (the tip commit's object id); each reversible with a leading `-`), `--ignore-case`, `-c`/`-C`/`--copy` (copy a branch and its upstream config, keeping the source; `-C` overwrites), `--column[=]`/`--no-column` (columnar list layout; `--no-column` countermands `--column`, last wins, and branches list one-per-line by default so `--no-column` alone is a no-op), `-v`/`--verbose` (list each branch with its tip sha and commit subject; `-vv` additionally shows the upstream-tracking segment `[: ahead N, behind M]`, with the same counts as `status`; only `[]` when the tracking ref is unresolved or the counts cannot be computed, the latter with a warning), and `--edit-description []` (edit `branch..description` in the configured editor; an empty/comment-only buffer unsets it) supported; `--format=` (render each branch via the for-each-ref atom engine — `%(refname)`/`%(objectname)`/`%(HEAD)`/`%(upstream)`/`%(align)`/`%(if)` etc.; replaces the default `* name` listing, `-v`, and `--column`) supported; the remaining for-each-ref sort keys (the `*`-deref keys, which are tag-only) are not exposed; `branch diff [] []` (lore.md 1.12, Libra extension porting Lore's branch diff): thin sugar over the diff engine — tip-to-tip (worktree never involved), byte-identical to `diff ..`; defaults subject = current branch, base = its upstream (none → error with hints); `--merge-base` = three-dot; curated `--stat`/`--name-only`/`--name-status`/`--exit-code` + `-- `; `--json` emits the diff schema; `branch diff` is a REFUSED reserved verb (never silently creates a branch named `diff`; escape hatch `libra switch -c diff`); `branch reset ` (lore.md 1.13, Libra extension porting Lore's branch reset): moves a LOCAL branch tip through the authoritative SQLite txn (reference + branch reflog entry; index/worktree untouched — pinned by test); the FIRST enforcement consumer of protect/archive metadata — both flags re-checked fail-closed INSIDE the txn (garbage values count as protected; LBR-POLICY-001; no `--force` — lift via `metadata unset`, auditable); the checked-out branch is refused (in-txn re-checked) with a `libra reset` hint; identical re-runs within the operation-log 5s dedup window are refused; `reset` joins `diff` as a reserved verb (escape hatch `switch -c reset`); `branch.sort` config default honored (strict cascade, `--sort` wins; config neither implies `--list` nor suppresses the unborn-HEAD line; invalid value fails closed `LBR-CLI-002` / unreadable store `LBR-IO-001` before output; repeated values apply only the last of the winning scope — Git stacks them); `-u/--set-upstream-to []` accepts a local branch as upstream (`branch..remote=.`, shown as `[main]` / `...main` by `-vv` and `status`); self-upstream warns and writes nothing; missing upstream/target or extra args use Git wording but exit **129** (`LBR-CLI-003` / `LBR-CLI-002`; Git uses 128 — intentional, ADR-HF-02); `-t/--track[=direct|inherit]` and `--no-track` apply on create (`direct` uses HF-07 parse order, `inherit` copies the start-point's upstream); a non-branch start-point is refused with Git wording but exit **129** (`LBR-CLI-003`; Git 128 — intentional, ADR-HF-02) and creates nothing; `--track` is ignored with `-d`/`-m`/`--list` or when used alone; default list order is refname (current branch is not promoted); `-v`/`-vv` pad the name column to the longest displayed name, including the detached first line `* (HEAD detached at )`; `-a` prefixes remote-tracking names as `remotes//`; `branch -d` refusals (not fully merged, missing branch, currently checked out) exit **1** (`LBR-REPO-003` / `LBR-CLI-003` unchanged; `LIBRA_FINE_EXIT_CODES=1` does not override); a local branch as upstream (`remote=.`); invalid `-u` targets exit 129 (Git: 128); `-t/--track[=direct|inherit]` and `--no-track` on create; `--track ` exits 129 (Git: 128); listed in refname order with `-v` columns aligned; other refusals keep their documented 128/129 exit codes; `branch -d` refusals exit 1 | -| bundle | partial | `create [...]` accepts explicit revisions plus `--all`/`--branches`/`--tags`, writes a bounded full Git v2 bundle through a private synced temp file, preserves annotated tag-object heads, and packs their reachable closure with the repository hash kind. `verify` checks header, prerequisites, PACK v2, and the complete trailer checksum; `list-heads` reads advertised heads; `unbundle` validates and installs a SHA-1/SHA-256 pack+index pair, verifies an already-installed pair on repeat, prints heads, and intentionally does not update refs (matching Git). System Git can clone the result. Exit 0 / 1 (`verify`/`list-heads` invalid/unreadable/missing prerequisite) / 128 (create/unbundle/repository/IO); input/output cap 1 GiB. Prerequisite/thin/incremental create, Libra clone-from-bundle, and exhaustive verify entry decoding are deferred | +| bundle | partial | `create [...]` accepts explicit revisions plus `--all`/`--branches`/`--tags`, writes a bounded full Git v2 bundle through a private synced temp file, advertises a `HEAD` line for `--all` and an explicit `HEAD` revision (issues/474 CL-08), preserves annotated tag-object heads, and packs their reachable closure with the repository hash kind. `verify` checks header, prerequisites, PACK v2, and the complete trailer checksum; `list-heads` reads advertised heads; `unbundle` validates and installs a SHA-1/SHA-256 pack+index pair, verifies an already-installed pair on repeat, prints heads, and intentionally does not update refs (matching Git). System Git can clone the result. Exit 0 / 1 (`verify`/`list-heads` invalid/unreadable/missing prerequisite) / 128 (create/unbundle/repository/IO); input/output cap 1 GiB. Prerequisite/thin/incremental create, Libra clone-from-bundle, and exhaustive verify entry decoding are deferred | | tag | partial | lightweight tags, message-based annotated tags (via `-m`/`-F`), `-F`/`--file` (annotated message from a file or stdin), force, delete, list, `-n`, `--points-at `, `--contains`/`--no-contains`, `--merged`/`--no-merged`, `--sort`, `--column[=]` (comma/space-separated `always`/`auto`/`never` + `column`/`row`/`plain` (fill order; `plain` = one column) + `dense`/`nodense` (column widths); column-major + nodense by default, laid out by terminal display width, byte-compatible with `git tag --column`; `--no-column` countermands it — equivalent to `--column=never`, last one wins, and tags list one-per-line by default so `--no-column` alone is a no-op), vault-PGP `-s`/`--sign` (with `--no-sign` to countermand it; last one wins, and tags are unsigned by default so `--no-sign` alone is a no-op), `-v`/`--verify`, and `-e`/`--edit` (compose or edit the annotated-tag message in an editor; comments stripped, an empty result aborts), and `-a/--annotate` creates an annotated tag (alone opens the editor; with `-m`/`-F`/`-e` it is annotated; combined with `-d`/`-l`/`-v` it is a usage error) supported; `-u` and Git GPG interoperability are not exposed; `tag.sort` config default honored (strict cascade, `--sort` wins; a configured sort never turns creation into a listing; unset lists `refname`-ascending matching Git — previously insertion order; invalid value fails closed `LBR-CLI-002` / unreadable store `LBR-IO-001` before output; repeated values apply only the last of the winning scope — Git stacks them) | | commit | partial | common Git commit surface plus `--date` (author date; overrides `GIT_AUTHOR_DATE`), `GIT_AUTHOR_*` / `GIT_COMMITTER_*` identity and date env overrides (Git env wins over config unless `user.useConfigOnly=true`; existing `LIBRA_COMMITTER_*` remains a lower-priority fallback), `--cleanup`, `--dry-run` (no message required; skips hook/editor, rerere, and post-commit automation side effects), `--fixup`, `--squash`, `-C/-c` (reuse message AND author metadata), `--trailer`, `--reset-author`, `-e/--edit` (open the editor even with `-m/-F/-C`; bare non-dry-run `commit` opens it too), `-v/--verbose` (staged diff in the editor template, stripped at the scissors line so it never enters the message; dry-run prints it directly), `--porcelain` (machine-readable status v1 preview of the would-be-committed state; like Git it implies `--dry-run` and does not create the commit; inert under `--json`), and `commit.status` / `--status` / `--no-status` (status is included by default; only an applicable editor/stripping-cleanup path reads the strict local→global→system Git boolean, with the last explicit CLI toggle overriding/bypassing it and invalid/unreadable config failing before auto-stage; `-m`, dry-run/porcelain, JSON, and non-stripping cleanup bypass the key; status is seeded only when an editor opens and cleanup strips comments, so `verbatim`/`whitespace`/`scissors` never leak it; `--dry-run -a` uses an isolated task-local index and persists no temporary auto-stage blob/LFS/tree object, so the live index is never replaced; verbose preview budgets changed HEAD/already-staged/auto-staged diff blobs before loading (32 MiB per blob, 64 MiB charged total, 4,096 objects) and repository scratch is capped at 256 MiB across runs; changed blobs without a bounded constant-memory local preflight (including remote-only or pack-without-index) are refused before loading and previews never rebuild pack indexes; real LFS auto-stage atomically replaces stale backups and honors `--sync-data` staging/destination durability, including write-through replacement on Windows), and the `commit.cleanup`/`commit.verbose` config keys (the default cleanup mode / verbose flag when the CLI flag is unset; an explicit `--cleanup`/`-v` overrides the config; config cascade local→global; an invalid value is fatal) supported; `commit.verbose` is on/off only (a `bool-or-int` value enables verbose when non-zero, but Libra's `-v` has no verbosity level — `commit.verbose=2` behaves like `true`, with no `-vv`/unstaged-diff rendering — and there is no `--no-verbose` to force verbose off for a single commit); `-t/--template` (use FILE as the initial message — seeds the editor, or used directly with `--no-edit`; falls back to the `commit.template` config; ignored when a message source is given; an unedited template aborts the commit), and `--no-gpg-sign` (force an unsigned commit — skips Libra's vault GPG signing for this commit; vault signing runs when `vault.signing=true` (the init default) and a vault unseal key is available, so this is a no-op only when signing would not have happened anyway) supported; clean `--amend --no-edit` rewrites `HEAD` and refreshes the committer date even when tree/parents/author/message are unchanged, so it never prints a successful amend summary for an unchanged ref; `-S`/`--gpg-sign` (force-sign; Libra's commit signing is instead driven by the `vault.signing` config) and `--allow-empty-message` (bypasses empty-message and unedited-template checks; does not imply `--allow-empty`); trailer-writer fixes (lore.md 1.9): `-s` combined with `--trailer` now forms ONE Git-parseable trailer block (previously two paragraphs), `--trailer` always separates from the body with a blank line, and `--cleanup=strip`/`default` now collapses consecutive blank lines instead of deleting every interior blank (Git-faithful — multi-paragraph messages and user-typed trailer blocks survive) | A successful non-dry-run commit that updates HEAD also clears a stopped single-commit cherry-pick or revert, and marks a remaining multi-commit sequence as concluded. During an in-progress merge, a regular commit records HEAD plus the merge targets as parents, prefills the saved merge message (`-m`/`-F` override; `--no-edit` keeps `# Conflicts:` comments), then clears merge state and applies a held autostash. `--amend` during a merge is refused (128). `--dry-run` previews without writing or printing a fake commit hash. After `merge --squash`, commit is single-parent, prefills `SQUASH_MSG`, and deletes that file. Partial `commit ` / `-o` stays 129 (intentional; Git is 128). An empty staged tree classifies the working tree like Git: clean (`nothing to commit, working tree clean`), untracked-only (`nothing added to commit but untracked files present`), or unstaged tracked (`no changes added to commit`); `-s` does not change the refusal. A comment-only `-t` template reports `aborting commit due to empty commit message`; an unedited non-empty template still reports `you did not edit the message`. Exit 128 / `LBR-REPO-003` is unchanged. Remaining unsupported interactive options (`-p`/`--patch`/`--interactive`) fail with `LBR-UNSUPPORTED-001` (D15). concludes an in-progress merge with a two-parent commit `LBR-UNSUPPORTED-001` diagnostic for unsupported interactive options `--allow-empty-message` is supported `-a` stages mode-only worktree changes when `core.filemode=true` (Unix default) and ignores them when `false` (plan-20260918 FM-04) | | switch | partial | `-` previous-checkout target (worktree-scoped HEAD reflog; local branch/current tip and detached/full-OID toggling; missing/deleted/corrupt target fails closed), `-C/--force-create`, `--orphan` (unborn branch: preserves index/worktree, first user commit has no parents, existing branch names fail closed), `--detach`, `--track`, `-f`/`--force` (alias `--discard-changes`; proceed despite local changes, discarding them when switching to a different commit), `--guess`/`--no-guess` (DWIM remote-tracking guess; default-on via `checkout.guess`, `checkout.defaultRemote` tie-break), and `--no-progress` (accepted no-op — Libra's switch renders no progress meter) supported; merge/conflict/submodule flags not exposed; case-collision preflight (lore.md 1.14): materializing a tree with fold-colliding paths on a case-insensitive view refuses atomically BEFORE the HEAD update and any worktree write under `core.casehandling=error` (LBR-CASE-001, groups listed), warns-and-proceeds under `warn` (git parity — git warns and clobbers), silent under `allow`; per-worktree HEAD isolation (§C.3.3/ADR-0714-09): each worktree owns its HEAD, and one branch may be checked out by at most one live worktree — switching to a branch already checked out in another worktree fails closed (`LBR-CONFLICT-002`, suggests `--detach`), while `--ignore-other-worktrees` is accepted for Git parity but never bypasses the refusal (a silent no-op in single-worktree repositories); bare `--detach` detaches at the current HEAD (unborn HEAD is refused with `You are on a branch yet to be born`, `LBR-REPO-003`, exit 128); bare `--detach` detaches at HEAD switching materializes the target entry's permission bits under the process `umask`, setting or clearing the execute bit as the entry mode requires (plan issues/470 FM-01, ADR-FM-02). | diff --git a/docs/commands/bundle.md b/docs/commands/bundle.md index f1db85247..210b775f3 100644 --- a/docs/commands/bundle.md +++ b/docs/commands/bundle.md @@ -16,9 +16,11 @@ libra bundle unbundle - `create` writes a full, non-thin bundle. Explicit revisions may be combined with `--all`, `--branches`, or `--tags`; at least one selector is required. - Annotated tag heads retain the tag-object OID and the pack includes tag target - closure. Output uses a private temporary file, syncs it, then renames it into - place. + `--all` and an explicit `HEAD` revision advertise a `HEAD` line (detached + `HEAD` points at the detached commit; attached `HEAD` stays `HEAD`, not + `refs/heads/`). Annotated tag heads retain the tag-object OID and the + pack includes tag target closure. Output uses a private temporary file, syncs + it, then renames it into place. - `verify` validates the v2 header, local prerequisites, pack version, and the complete pack checksum. - `list-heads` prints the advertised ` ` lines without importing. @@ -37,8 +39,8 @@ full-history only; prerequisite/thin/incremental range creation remains deferred | Option | Description | |---|---| -| `...` | Include explicit revisions as advertised heads. | -| `--all` | Include all local branches and tags. | +| `...` | Include explicit revisions as advertised heads. `HEAD` is advertised as `HEAD`. | +| `--all` | Include all local branches and tags, plus a `HEAD` line. | | `--branches` | Include all local branches. | | `--tags` | Include all local tags, preserving annotated objects. | diff --git a/docs/commands/zh-CN/bundle.md b/docs/commands/zh-CN/bundle.md index bb29b4b8e..c4e9d197f 100644 --- a/docs/commands/zh-CN/bundle.md +++ b/docs/commands/zh-CN/bundle.md @@ -13,7 +13,7 @@ libra bundle unbundle ## 说明 -- `create` 写完整、非 thin bundle。显式修订可与 `--all`、`--branches`、`--tags` 组合,且至少需要一种选择。annotated tag head 保留 tag 对象 OID,pack 包含其目标闭包。输出先写入私有临时文件并同步,再 rename 到目标。 +- `create` 写完整、非 thin bundle。显式修订可与 `--all`、`--branches`、`--tags` 组合,且至少需要一种选择。`--all` 与显式 `HEAD` 会广告一行 `HEAD`(分离 HEAD 指向该提交;附着 HEAD 仍写 `HEAD`,不改写成 `refs/heads/`)。annotated tag head 保留 tag 对象 OID,pack 包含其目标闭包。输出先写入私有临时文件并同步,再 rename 到目标。 - `verify` 校验 v2 头、本地 prerequisite、pack 版本与完整 pack checksum。 - `list-heads` 只打印 ` ` advertised heads,不导入对象。 - `unbundle` 校验 prerequisite/checksum,构建正确的 SHA-1 或 SHA-256 pack index,并把 pack/index 对装入对象库。它打印 heads,但按 `git bundle unbundle` 语义**不更新 refs**。重复导入会先核对已安装 pair,再报告成功。 @@ -24,8 +24,8 @@ bundle 输入、收集的原始对象数据和最终输出各自以 1 GiB 为上 | 选项 | 说明 | |---|---| -| `...` | 把显式修订作为 advertised heads 包含。 | -| `--all` | 包含全部本地分支和 tag。 | +| `...` | 把显式修订作为 advertised heads 包含。`HEAD` 广告为 `HEAD`。 | +| `--all` | 包含全部本地分支和 tag,并写入一行 `HEAD`。 | | `--branches` | 包含全部本地分支。 | | `--tags` | 包含全部本地 tag,并保留 annotated 对象。 | diff --git a/docs/development/plan/issues/474.md b/docs/development/plan/issues/474.md index 9c00aa62b..b6acf7336 100644 --- a/docs/development/plan/issues/474.md +++ b/docs/development/plan/issues/474.md @@ -1142,7 +1142,7 @@ ER-CL-02 接入 / 豁免清单(2026-09-23): **Task type:** `implementation` -**Lifecycle / Acceptance:** `pending` / 空 +**Lifecycle / Acceptance:** `in-progress` / `locally-accepted` **Description:** 按 ADR-CL-06 第 4 条,让 `bundle create --all` 与显式 `HEAD` 参数在 bundle 头部写入 `HEAD` 行(分离 HEAD 时指向提交),使 Git 与 Libra 都能从中检出。唯一行为轴是「bundle 的 HEAD 广告」。 @@ -1158,17 +1158,17 @@ ER-CL-02 接入 / 豁免清单(2026-09-23): **Acceptance criteria:** -- [ ] 判据矩阵 M-BCREATE C1(`--all` 含 `HEAD`)。 -- [ ] 显式 `HEAD` 通过 M-BCREATE C2。 -- [ ] 分离 HEAD 通过 M-BCREATE C3。 -- [ ] Git 可检出(夹具验证:由测试写出 bundle 后比对头部格式,Git 克隆结果作为手工证据)通过 M-BCREATE C4。 -- [ ] ER-06a 同步:`docs/commands/bundle.md` 与 zh-CN、`COMPATIBILITY.md:207` 原行追加、网站 `bundle.en.md`;`bundle verify`/`unbundle` 回归(C5)。 +- [x] 判据矩阵 M-BCREATE C1(`--all` 含 `HEAD`)。 +- [x] 显式 `HEAD` 通过 M-BCREATE C2。 +- [x] 分离 HEAD 通过 M-BCREATE C3。 +- [x] Git 可检出(夹具验证:由测试写出 bundle 后比对头部格式,Git 克隆结果作为手工证据)通过 M-BCREATE C4。 +- [x] ER-06a 同步:`docs/commands/bundle.md` 与 zh-CN、`COMPATIBILITY.md` bundle 原行追加、网站 `bundle.en.md`(`cf` 已含 HEAD 句);`bundle verify`/`unbundle` 回归(C5)。 **Verification:** -- [ ] `source .env.test && source .env.live-test && cargo nextest run --lib command::bundle`(new:heads 收集含 `HEAD` 单测) -- [ ] `source .env.test && source .env.live-test && cargo nextest run --test command_test bundle_test`(new:`test_bundle_create_advertises_head_matrix`) -- [ ] 手工证据:git 2.54 从 Libra bundle 克隆的输出(存 `LIBRA_TEST_SCRATCH_DIR`) +- [x] `source .env.test && source .env.live-test && cargo nextest run --lib command::bundle`(new:`advertised_name_for_explicit_head_is_head`) +- [x] `source .env.test && source .env.live-test && cargo nextest run --test command_test bundle_test`(new:`test_bundle_create_advertises_head_matrix`) +- [x] 手工证据:git 2.54 从 Libra bundle 克隆的输出(矩阵 C4 在测试内执行 `git clone`) **Full-suite trigger:** `none` diff --git a/docs/development/plan/plan-status.md b/docs/development/plan/plan-status.md index f68087d5c..54917864c 100644 --- a/docs/development/plan/plan-status.md +++ b/docs/development/plan/plan-status.md @@ -61,7 +61,7 @@ |---|---|---|---| | [`issues/470.md`](issues/470.md) | 工作树物化丢失可执行位与 mode 变化检测 | 未启动 | FM-01/02/05(3 卡) | | [`issues/473.md`](issues/473.md) | `init` 与 Git 对齐 | 未启动 | IN-01..IN-12(12 卡) | -| [`issues/474.md`](issues/474.md) | clone 浅克隆完整性、bundle 源、bare 与 mirror 对齐 | 实施中 | CL-01 `done`/`complete`(`v0.23.47`);CL-02 `done`/`complete`(`v0.23.48`);CL-03 C 组 `v0.23.49`(`compat-network-remotes` 自托管 runner 掉线后重跑中);CL-04..CL-07 `locally-accepted`(未 bump) | +| [`issues/474.md`](issues/474.md) | clone 浅克隆完整性、bundle 源、bare 与 mirror 对齐 | 实施中 | CL-01 `done`/`complete`(`v0.23.47`);CL-02 `done`/`complete`(`v0.23.48`);CL-03 C 组 `v0.23.49`(`compat-network-remotes` 重跑排队中);CL-04..CL-08 `locally-accepted`(未 bump) | | [`issues/475.md`](issues/475.md) | `config` Git 兼容参数层对齐 | 未启动 | CF-01..CF-15(15 卡) | | [`issues/476.md`](issues/476.md) | 工作树命令族与 Git 对齐 | **实施中** | WT-02 `v0.23.29` / WT-04 `v0.23.30` / WT-08 `v0.23.31` / WT-09 `v0.23.32` / WT-10 `v0.23.33` / WT-11 `v0.23.34` / WT-01 `v0.23.35`(`done`/`remote-pending`);WT-03 受 DEP-WT-08 阻塞;intent-to-add 已迁至 plan-20260918 | | [`issues/477.md`](issues/477.md) | 历史改写命令族与 Git 对齐 | **已收口** | HF-01..HF-31(31 卡)全 `done/complete`,聚合发布 v0.22.49;子 issue #495 | @@ -182,7 +182,8 @@ | **CL-05 `--depth` 隐含单分支** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-04 发布窗口)** | | **CL-06 普通路径忽略浅化参数** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-05 发布窗口)** | | **CL-07 shallow Git 源克隆** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-06 发布窗口)** | -| CL-08..CL-15 | `pending` | 未 bump | +| **CL-08 bundle create 写入 HEAD** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-07 发布窗口)** | +| CL-09..CL-15 | `pending` | 未 bump | ### 3.6 issues/476(工作树命令族) @@ -222,8 +223,8 @@ SBX-01..05 `done/locally-accepted`;**发布步按 DEFER-SBX-06 正式延后** ## 四、当前执行指针(next action) -- **当前正在执行:** `issues/474` → `CL-03` 发布窗口(`v0.23.49`,PR #512)。`compat-network-remotes` 因自托管 runner 掉线失败后已 `--failed` 重跑;其余 base.yml 作业此前已绿。`CL-04`..`CL-07` 已 `locally-accepted`,均未 bump。 -- **下一步(CL-03 D 组落地后):** 按窗口依次 bump 并发布 `CL-04` → `CL-07`。 +- **当前正在执行:** `issues/474` → `CL-03` 发布窗口(`v0.23.49`,PR #512)。`compat-network-remotes` 因自托管 runner 掉线失败后已 `--failed` 重跑;其余 base.yml 作业此前已绿。`CL-04`..`CL-08` 已 `locally-accepted`,均未 bump。 +- **下一步(CL-03 D 组落地后):** 按窗口依次 bump 并发布 `CL-04` → `CL-08`。 - **并行窗口(不在本执行指针):** `issues/476` WT-03 仍等 DEP-WT-08;`plan-20260918` 其余 add 卡、`plan-20260819` M2 仍登记为实施中,但不抢本卡的 `fsck.rs` 写集。 --- diff --git a/src/command/bundle.rs b/src/command/bundle.rs index 91987bed8..d6b44f528 100644 --- a/src/command/bundle.rs +++ b/src/command/bundle.rs @@ -149,7 +149,8 @@ async fn create( include_branches: bool, include_tags: bool, ) -> CliResult<()> { - let heads = collect_bundle_heads(revs, all || include_branches, all || include_tags).await?; + let heads = + collect_bundle_heads(revs, all || include_branches, all || include_tags, all).await?; // Collect every object reachable from the tips (deduplicated). let mut seen: HashSet = HashSet::new(); @@ -226,6 +227,7 @@ async fn collect_bundle_heads( revs: &[String], include_branches: bool, include_tags: bool, + include_head: bool, ) -> CliResult> { let mut branches = Branch::list_branches_result(None).await.map_err(|error| { CliError::fatal(format!("failed to list branches for bundle: {error}")) @@ -274,6 +276,9 @@ async fn collect_bundle_heads( heads.push(head); } }; + if include_head && let Some(head) = current_head_advertisement().await? { + add(head); + } if include_branches { for branch in &branches { add(BundleHead { @@ -314,14 +319,10 @@ async fn collect_bundle_heads( .with_exit_code(128) .with_stable_code(StableErrorCode::CliInvalidTarget) })?; - let name = if rev == "HEAD" { - resolve_ref_name(rev).await - } else if rev.starts_with("refs/") { - rev.clone() - } else { - format!("refs/heads/bundle-export-{}", index + 1) - }; - add(BundleHead { oid, name }); + add(BundleHead { + oid, + name: advertised_name_for_rev(rev, index), + }); } if heads.is_empty() { @@ -908,21 +909,31 @@ fn split_oid_rest(line: &str) -> (String, String) { // shared helpers // ---------------------------------------------------------------------------- -/// Resolve the ref name a revision should be recorded under in the header. -async fn resolve_ref_name(rev: &str) -> String { +/// Advertised header name for an explicit revision after branch/tag lookup. +/// `HEAD` stays `HEAD` (Git bundle create), including when attached to a branch. +fn advertised_name_for_rev(rev: &str, index: usize) -> String { if rev == "HEAD" { - return match Head::current().await { - Head::Branch(name) => format!("refs/heads/{name}"), - Head::Detached(_) => "HEAD".to_string(), - }; - } - if rev.starts_with("refs/") { + "HEAD".to_string() + } else if rev.starts_with("refs/") { rev.to_string() } else { - format!("refs/heads/{rev}") + format!("refs/heads/bundle-export-{}", index + 1) } } +/// Current repository HEAD as a bundle advertisement, or `None` when unborn. +async fn current_head_advertisement() -> CliResult> { + let oid = Head::current_commit_result().await.map_err(|error| { + CliError::fatal(format!("failed to resolve HEAD for bundle: {error}")) + .with_exit_code(128) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + Ok(oid.map(|oid| BundleHead { + oid, + name: "HEAD".to_string(), + })) +} + fn object_error(id: &ObjectHash, error: git_internal::errors::GitError) -> CliError { CliError::fatal(format!("failed to load object {id}: {error}")) .with_exit_code(128) @@ -947,6 +958,19 @@ fn write_err(error: std::io::Error) -> CliError { mod tests { use super::*; + #[test] + fn advertised_name_for_explicit_head_is_head() { + assert_eq!(advertised_name_for_rev("HEAD", 0), "HEAD"); + assert_eq!( + advertised_name_for_rev("refs/heads/main", 0), + "refs/heads/main" + ); + assert_eq!( + advertised_name_for_rev("orphan-oid", 2), + "refs/heads/bundle-export-3" + ); + } + #[test] fn parses_a_v2_header_with_heads() { let oid = "a".repeat(get_hash_kind().hex_len()); diff --git a/tests/command/bundle_test.rs b/tests/command/bundle_test.rs index eb717841b..16eb3a2ae 100644 --- a/tests/command/bundle_test.rs +++ b/tests/command/bundle_test.rs @@ -2,11 +2,47 @@ //! //! Layer: L1 (deterministic; tempdir + isolated HOME, no network). -use std::fs; +use std::{fs, path::Path, process::Command}; use tempfile::tempdir; -use super::{create_committed_repo_via_cli, run_libra_command}; +use super::{assert_cli_success, create_committed_repo_via_cli, run_libra_command}; + +fn advertised_head_names(repo: &Path, bundle: &Path) -> Vec { + let result = run_libra_command(&["bundle", "list-heads", bundle.to_str().unwrap()], repo); + assert_eq!( + result.status.code(), + Some(0), + "list-heads failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8_lossy(&result.stdout) + .lines() + .filter_map(|line| line.split_once(' ').map(|(_, name)| name.to_string())) + .collect() +} + +fn advertised_head_pairs(repo: &Path, bundle: &Path) -> Vec<(String, String)> { + let result = run_libra_command(&["bundle", "list-heads", bundle.to_str().unwrap()], repo); + assert_eq!(result.status.code(), Some(0)); + String::from_utf8_lossy(&result.stdout) + .lines() + .filter_map(|line| { + line.split_once(' ') + .map(|(oid, name)| (oid.to_string(), name.to_string())) + }) + .collect() +} + +fn repo_with_branch_and_tag() -> tempfile::TempDir { + let repo = create_committed_repo_via_cli(); + assert_cli_success( + &run_libra_command(&["branch", "dev"], repo.path()), + "create dev", + ); + assert_cli_success(&run_libra_command(&["tag", "v1"], repo.path()), "tag v1"); + repo +} #[test] fn bundle_create_writes_a_v2_bundle() { @@ -28,7 +64,10 @@ fn bundle_create_writes_a_v2_bundle() { "missing v2 signature" ); let text = String::from_utf8_lossy(&bytes); - assert!(text.contains("refs/heads/"), "missing a head ref line"); + assert!( + text.contains(" HEAD\n") || text.lines().any(|line| line.ends_with(" HEAD")), + "explicit HEAD must advertise a HEAD line: {text}" + ); // The pack follows the blank line that terminates the header. assert!( bytes.windows(6).any(|w| w == b"\n\nPACK"), @@ -54,7 +93,14 @@ fn bundle_list_heads_prints_refs() { repo.path(), ); assert_eq!(result.status.code(), Some(0)); - assert!(String::from_utf8_lossy(&result.stdout).contains("refs/heads/")); + assert!( + String::from_utf8_lossy(&result.stdout).contains(" HEAD") + || String::from_utf8_lossy(&result.stdout) + .lines() + .any(|line| line.ends_with(" HEAD")), + "list-heads must print the HEAD advertisement: {}", + String::from_utf8_lossy(&result.stdout) + ); } #[test] @@ -113,3 +159,109 @@ fn bundle_outside_repository_is_an_error() { ); assert_eq!(result.status.code(), Some(128)); } + +/// M-BCREATE C1–C5: `--all` / explicit `HEAD` advertise a `HEAD` line. +#[test] +fn test_bundle_create_advertises_head_matrix() { + let repo = repo_with_branch_and_tag(); + let all = repo.path().join("all.bundle"); + assert_cli_success( + &run_libra_command( + &["bundle", "create", all.to_str().unwrap(), "--all"], + repo.path(), + ), + "C1 bundle create --all", + ); + let c1 = advertised_head_names(repo.path(), &all); + for expected in ["HEAD", "refs/heads/main", "refs/heads/dev", "refs/tags/v1"] { + assert!( + c1.iter().any(|name| name == expected), + "C1 missing {expected} in {c1:?}" + ); + } + + let head_main = repo.path().join("head-main.bundle"); + assert_cli_success( + &run_libra_command( + &[ + "bundle", + "create", + head_main.to_str().unwrap(), + "HEAD", + "main", + ], + repo.path(), + ), + "C2 bundle create HEAD main", + ); + let c2 = advertised_head_names(repo.path(), &head_main); + assert!( + c2.iter().any(|name| name == "HEAD"), + "C2 missing HEAD: {c2:?}" + ); + assert!( + c2.iter().any(|name| name == "refs/heads/main"), + "C2 missing refs/heads/main: {c2:?}" + ); + + assert_cli_success( + &run_libra_command(&["switch", "--detach"], repo.path()), + "C3 switch --detach", + ); + let detached_oid = + String::from_utf8_lossy(&run_libra_command(&["rev-parse", "HEAD"], repo.path()).stdout) + .trim() + .to_string(); + let detached = repo.path().join("detached.bundle"); + assert_cli_success( + &run_libra_command( + &["bundle", "create", detached.to_str().unwrap(), "--all"], + repo.path(), + ), + "C3 bundle create --all (detached)", + ); + let c3 = advertised_head_pairs(repo.path(), &detached); + let head_line = c3 + .iter() + .find(|(_, name)| name == "HEAD") + .expect("C3 must advertise HEAD"); + assert_eq!( + head_line.0, detached_oid, + "C3 HEAD must point at the detached commit" + ); + + let git_dest = repo.path().join("from-git"); + let git = Command::new("git") + .args(["clone", all.to_str().unwrap(), git_dest.to_str().unwrap()]) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", repo.path().join(".git-home")) + .output() + .expect("spawn git clone"); + assert!( + git.status.success(), + "C4 git clone bundle failed: {}{}", + String::from_utf8_lossy(&git.stdout), + String::from_utf8_lossy(&git.stderr) + ); + let log = Command::new("git") + .args(["-C", git_dest.to_str().unwrap(), "log", "--oneline"]) + .output() + .expect("spawn git log"); + assert!(log.status.success(), "C4 git log failed"); + assert!( + !String::from_utf8_lossy(&log.stdout).trim().is_empty(), + "C4 git clone must check out a non-empty history" + ); + + let verify = run_libra_command(&["bundle", "verify", all.to_str().unwrap()], repo.path()); + assert_cli_success(&verify, "C5 verify"); + assert!( + String::from_utf8_lossy(&verify.stdout).contains("is okay"), + "C5 verify: {}", + String::from_utf8_lossy(&verify.stdout) + ); + assert_cli_success( + &run_libra_command(&["bundle", "unbundle", all.to_str().unwrap()], repo.path()), + "C5 unbundle", + ); +} From 391eb60021d7c21b1e1c9a6b287b13be9fdfb547 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Wed, 23 Sep 2026 20:58:43 +0800 Subject: [PATCH 2/2] chore(release): bump version to 0.23.54 Signed-off-by: Eli Ma --- Cargo.lock | 2 +- Cargo.toml | 2 +- install.ps1 | 2 +- install.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5eb914cc5..67b858909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3783,7 +3783,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libra" -version = "0.23.53" +version = "0.23.54" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index d8e040882..b33d6c39c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libra" -version = "0.23.53" +version = "0.23.54" edition = "2024" license = "MIT" description = "AI agent-native version control system with Git on-disk compatibility, SQLite-backed metadata, and tiered cloud storage" diff --git a/install.ps1 b/install.ps1 index 2012cf70c..ff21de77e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -22,7 +22,7 @@ $ErrorActionPreference = "Stop" # One of the release version surfaces. `compat_version_surface_sync` pins it # to Cargo.toml: this value is substituted verbatim into the download URL, so # a stale value silently installs an old binary when -Version is not given. -$DefaultVersion = "v0.23.53" +$DefaultVersion = "v0.23.54" # Public-only trust anchor for stable-manifest verification. It deliberately # has no environment override: the install-smoke harness rewrites these # clearly-marked constants in a temporary COPY of this script. diff --git a/install.sh b/install.sh index a153b18b1..1a4602c74 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ INSTALL_DIR="${LIBRA_INSTALL_DIR:-$LIBRA_HOME/bin}" # user opts in with LIBRA_ALLOW_FALLBACK=1. Default behaviour is fail-fast so # offline installs cannot silently regress to a stale version. Bump this on # every release so the opt-in fallback remains useful. -DEFAULT_VERSION="v0.23.53" +DEFAULT_VERSION="v0.23.54" # Public-only trust anchor for stable-manifest verification. It deliberately # has no environment override: the install-smoke harness rewrites these # clearly-marked constants in a temporary COPY of this script, never through