diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 53c4cde77..a9b402f3a 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -161,7 +161,7 @@ there is no promised all-phase 30-second hard deadline. See | Command | Tier | Notes | |---------|------|-------| | init | partial | common initialization plus Git-style safe re-initialization/top-up of an existing repo (prints `Reinitialized existing ...`, re-creates missing layout, re-applies `--shared`, persists `core.sharedRepository`, preserves config/HEAD/refs/objects/vault/repo-id otherwise) are supported; when `-b/--initial-branch` is omitted for a new repo, `init.defaultBranch` is read local→global→system with case-insensitive variable matching, local/global encrypted values decrypted, and `main` fallback; empty/invalid values fail before layout with `LBR-CLI-002`, local/global config DB read failures use `LBR-IO-001` (except a future-schema global store — newer than this binary — which is skipped with a one-time warning, see `LBR-CONFIG-001`), and unreadable/unsupported system scope is skipped; `--from-git-repository` reports the source `HEAD` branch instead of applying the configured default; numeric `--shared=` is prevalidated so non-traversable modes such as `0660` fail before a partial `.libra` is created; recursive submodule init is not; the per-user Libra home and global config directory are reserved: init refuses them as storage roots, discovery skips stray home databases (including commondir targets), and opening an older database restores a missing legacy config table without deleting existing configuration (#472) | -| clone | partial | `--depth`, `--single-branch`/`--no-single-branch` (toggle; `--no-single-branch` countermands `--single-branch`, last wins, and clone fetches all branches by default so `--no-single-branch` alone is a no-op), `--tags`/`--no-tags` (clone fetches all tags by default like Git; `--no-tags` skips them and records `remote..tagOpt=--no-tags`, where `` is the remote name — `origin` by default or the `-o`/`--origin` value), and `--no-progress` (suppresses the fetch "Receiving objects" progress meter during the clone, like `git clone --no-progress`), `--no-checkout` (set up objects/refs/HEAD but do not check out the working tree), and `-o`/`--origin ` (name the remote and its tracking refs instead of `origin`; standard clones only), `-l`/`--local` and `--no-local` (accepted no-ops — Libra never hardlinks objects (it always copies), and how it reads a local-path source is determined by the source type — a local Libra repo is read directly, a local Git repo is read in-process (no `git-upload-pack` dependency) — not by these flags), and `--reject-shallow` (fail when the clone is shallow without `--depth`, i.e. a shallow source — exit 128; `--depth` is allowed and, as a documented narrowing vs Git, suppresses the check since Libra cannot distinguish a shallow source from `--depth`-induced shallowness), and the object-alternates flags: `--shared`/`-s` (lore.md 2.11 — for a LOCAL Libra source, registers the source's object store as an alternate of the clone via lore.md 2.3's guarded path: borrowed reads + base gc/evict/obliterate protection; NON-FATAL on any guard/io failure — the clone still succeeds; v1 STILL COPIES every object, so this only adds the borrow link + base protection, NOT disk savings — copy-avoidance is deferred; a no-op warning for a remote/local-Git source), `--no-shared` (countermands `--shared` / a `clone.shared=true` default), the `clone.shared` config (global default; default OFF), `--dissociate` (forces no-share — a self-contained clone), and `--reference `/`--reference-if-able ` (still accepted no-ops — Libra has no fetch-side alternate negotiation yet, so `--reference` copies + warns and `--reference-if-able` is silently ignored; use `libra alternates add` to borrow), and `--mirror` (implies `--bare`; maps every fetched branch verbatim into `refs/heads/*` and keeps tags, drops the `refs/remotes/*` tracking refs, and records the `remote..mirror=true` marker. Documented narrowings: Libra mirrors only what it fetches — `refs/notes/*` and other un-fetched namespaces are not mirrored, and because fetch collapses `refs/mr/*` into the branch tracking namespace those refs are mirrored as `refs/heads/mr/*`; the marker is informational since `libra fetch` is not yet mirror-aware, so no `+refs/*:refs/*` refspec is recorded), and the fetch-optimization flags `--filter `/`--shallow-since `/`--shallow-exclude ` (accepted no-ops for Git remotes — Libra has no partial-clone/promisor support and its fetch only does `--depth` shallow, so each is ignored with a warning and the optimization is simply not applied — the clone fetches everything those flags would trim, subject only to `--depth` if also given (without `--depth`, a complete clone, a correct superset), matching Git's own full-clone fallback when a server cannot filter), and the dependency-filtered clone flags `--deps-of ` (repeatable) / `--deps-depth-limit ` (lore.md 3.2, intentionally-different — Git has no file-dependency concept; NOT partial-clone/`--filter` and NOT `--sparse`/D10): after a normal FULL, commit-safe checkout, scope the read-only sparse VIEW (2.2) to the forward dependency closure (3.1) of the given roots and record `remote..fetchNotesDeps=true`; implies `--notes` (the graph is fetched to compute the closure). Objects are NEVER wire-filtered — the whole pack is downloaded and the whole tree stays on disk (working-tree disk narrowing is deferred, D18); only a local Libra source can travel the graph (D17), a network/foreign-Git remote performs a full clone without scoping and warns; conflicts with `--no-checkout`/`--bare`/`--mirror` supported; `--sparse` unsupported (see [docs/development/commands/_compatibility.md#d10-clone---sparse-与顶层-sparse-checkout-命令](docs/development/commands/_compatibility.md#d10-clone---sparse-与顶层-sparse-checkout-命令)); `--recurse-submodules` unsupported (see [docs/development/commands/_compatibility.md#d4-clone---recurse-submodules](docs/development/commands/_compatibility.md#d4-clone---recurse-submodules)) checked-out entries carry the tree mode's permission bits (`100755` executable, `100644` plain) under the process `umask`, and replacing an existing file clears a stale execute bit (plan issues/470 FM-01, ADR-FM-02). A local Git source via `file://` or `--no-local` truncates by shortest distance from any want, then one boundary pass (a missing parent, or a root exactly on the depth cutoff, is written to `.libra/shallow`; issues/474 CL-04). `--depth` / `--shallow-since` / `--shallow-exclude` imply `--single-branch` unless `--no-single-branch` or `--mirror`; that clone writes `remote..fetch=+refs/heads/:refs/remotes//` (issues/474 CL-05). A plain filesystem Git path ignores `--depth` / `--shallow-since` / `--shallow-exclude` / `--filter` and prints Git's local-clone warnings (`--quiet` still warns); `file://` and `--no-local` keep transport shallow semantics (issues/474 CL-06). A local Git shallow source is cloned with its `.git/shallow` boundaries merged into `.libra/shallow`; `--reject-shallow` inspects the source before creating the destination and leaves no residual (issues/474 CL-07). | +| clone | partial | `--depth`, `--single-branch`/`--no-single-branch` (toggle; `--no-single-branch` countermands `--single-branch`, last wins, and clone fetches all branches by default so `--no-single-branch` alone is a no-op), `--tags`/`--no-tags` (clone fetches all tags by default like Git; `--no-tags` skips them and records `remote..tagOpt=--no-tags`, where `` is the remote name — `origin` by default or the `-o`/`--origin` value), and `--no-progress` (suppresses the fetch "Receiving objects" progress meter during the clone, like `git clone --no-progress`), `--no-checkout` (set up objects/refs/HEAD but do not check out the working tree), and `-o`/`--origin ` (name the remote and its tracking refs instead of `origin`; standard clones only), `-l`/`--local` and `--no-local` (accepted no-ops — Libra never hardlinks objects (it always copies), and how it reads a local-path source is determined by the source type — a local Libra repo is read directly, a local Git repo is read in-process (no `git-upload-pack` dependency) — not by these flags), and `--reject-shallow` (fail when the clone is shallow without `--depth`, i.e. a shallow source — exit 128; `--depth` is allowed and, as a documented narrowing vs Git, suppresses the check since Libra cannot distinguish a shallow source from `--depth`-induced shallowness), and the object-alternates flags: `--shared`/`-s` (lore.md 2.11 — for a LOCAL Libra source, registers the source's object store as an alternate of the clone via lore.md 2.3's guarded path: borrowed reads + base gc/evict/obliterate protection; NON-FATAL on any guard/io failure — the clone still succeeds; v1 STILL COPIES every object, so this only adds the borrow link + base protection, NOT disk savings — copy-avoidance is deferred; a no-op warning for a remote/local-Git source), `--no-shared` (countermands `--shared` / a `clone.shared=true` default), the `clone.shared` config (global default; default OFF), `--dissociate` (forces no-share — a self-contained clone), and `--reference `/`--reference-if-able ` (still accepted no-ops — Libra has no fetch-side alternate negotiation yet, so `--reference` copies + warns and `--reference-if-able` is silently ignored; use `libra alternates add` to borrow), and `--mirror` (implies `--bare`; maps every fetched branch verbatim into `refs/heads/*` and keeps tags, drops the `refs/remotes/*` tracking refs, and records the `remote..mirror=true` marker. Documented narrowings: Libra mirrors only what it fetches — `refs/notes/*` and other un-fetched namespaces are not mirrored, and because fetch collapses `refs/mr/*` into the branch tracking namespace those refs are mirrored as `refs/heads/mr/*`; the marker is informational since `libra fetch` is not yet mirror-aware, so no `+refs/*:refs/*` refspec is recorded), and the fetch-optimization flags `--filter `/`--shallow-since `/`--shallow-exclude ` (accepted no-ops for Git remotes — Libra has no partial-clone/promisor support and its fetch only does `--depth` shallow, so each is ignored with a warning and the optimization is simply not applied — the clone fetches everything those flags would trim, subject only to `--depth` if also given (without `--depth`, a complete clone, a correct superset), matching Git's own full-clone fallback when a server cannot filter), and the dependency-filtered clone flags `--deps-of ` (repeatable) / `--deps-depth-limit ` (lore.md 3.2, intentionally-different — Git has no file-dependency concept; NOT partial-clone/`--filter` and NOT `--sparse`/D10): after a normal FULL, commit-safe checkout, scope the read-only sparse VIEW (2.2) to the forward dependency closure (3.1) of the given roots and record `remote..fetchNotesDeps=true`; implies `--notes` (the graph is fetched to compute the closure). Objects are NEVER wire-filtered — the whole pack is downloaded and the whole tree stays on disk (working-tree disk narrowing is deferred, D18); only a local Libra source can travel the graph (D17), a network/foreign-Git remote performs a full clone without scoping and warns; conflicts with `--no-checkout`/`--bare`/`--mirror` supported; `--sparse` unsupported (see [docs/development/commands/_compatibility.md#d10-clone---sparse-与顶层-sparse-checkout-命令](docs/development/commands/_compatibility.md#d10-clone---sparse-与顶层-sparse-checkout-命令)); `--recurse-submodules` unsupported (see [docs/development/commands/_compatibility.md#d4-clone---recurse-submodules](docs/development/commands/_compatibility.md#d4-clone---recurse-submodules)) checked-out entries carry the tree mode's permission bits (`100755` executable, `100644` plain) under the process `umask`, and replacing an existing file clears a stale execute bit (plan issues/470 FM-01, ADR-FM-02). A local Git source via `file://` or `--no-local` truncates by shortest distance from any want, then one boundary pass (a missing parent, or a root exactly on the depth cutoff, is written to `.libra/shallow`; issues/474 CL-04). `--depth` / `--shallow-since` / `--shallow-exclude` imply `--single-branch` unless `--no-single-branch` or `--mirror`; that clone writes `remote..fetch=+refs/heads/:refs/remotes//` (issues/474 CL-05). A plain filesystem Git path ignores `--depth` / `--shallow-since` / `--shallow-exclude` / `--filter` and prints Git's local-clone warnings (`--quiet` still warns); `file://` and `--no-local` keep transport shallow semantics (issues/474 CL-06). A local Git shallow source is cloned with its `.git/shallow` boundaries merged into `.libra/shallow`; `--reject-shallow` inspects the source before creating the destination and leaves no residual (issues/474 CL-07). A local Git v2 bundle is accepted after a repository directory is ruled out (`.bundle`, then ``), the default destination drops `.bundle`, and `--depth` on a plain-path bundle is ignored with the local-clone warning (issues/474 CL-09). | | automation | intentionally-different | Libra AI automation rules/history extension, not a Git command. Linked worktrees load `automations.toml` through the unified resolver and dispatch VCS events there; damaged/unreadable scope still fail-closes. | | sandbox | intentionally-different | Libra AI sandbox diagnostics extension, not a Git command | | cache | intentionally-different | Diagnostic for the tiered-storage / LRU cache (`cache info`): reports the resolved `LIBRA_STORAGE_TYPE`, whether a durable tier is active, and the `LIBRA_STORAGE_THRESHOLD` (small/large threshold) + `LIBRA_STORAGE_CACHE_SIZE` (LRU disk budget) tunables. Git has no equivalent; needs no repository. `--json`/`--machine` → `{ storage_type, tiered, threshold_bytes, cache_size_bytes }`. Exit 0 (non-zero if a storage config value cannot be resolved, e.g. an unreadable global config DB); `cache evict [--dry-run] [--max-size ] [--min-age ]` (lore.md 2.9): evicts verified-durable LARGE loose objects oldest-first until under the configured budget — every unlink is gated on an error-aware durability probe run immediately before it (confirmed-absent objects are skipped with a push hint; probe ERRORS are never treated as absence; 3 leading probe failures abort with nothing deleted); presence≠integrity residual documented; tiered `get` now self-heals a vanished local file from the durable tier; local-only repos report nothing-evictable; offline read policy refuses; also runnable as `maintenance run --task cache-evict` (excluded from the default task set) — and `maintenance loose-objects` no longer packs >=threshold cache residents under a tiered config | @@ -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, 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 | +| 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. `libra clone` accepts a Git v2 bundle after ruling out a repository directory (`.bundle`, then ``); the default destination drops `.bundle`; `HEAD` / default-branch / no-branch selection matches Git; `remote.origin.url` is the bundle path; `--depth` on a plain-path bundle is ignored with the local-clone warning (issues/474 CL-09). Prerequisite/thin/incremental create 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/Cargo.lock b/Cargo.lock index e8d4c28b7..32691cfa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3783,7 +3783,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libra" -version = "0.23.55" +version = "0.23.56" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index b7cfc8d92..c78c07f79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libra" -version = "0.23.55" +version = "0.23.56" 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/docs/commands/bundle.md b/docs/commands/bundle.md index 210b775f3..665d3c584 100644 --- a/docs/commands/bundle.md +++ b/docs/commands/bundle.md @@ -77,6 +77,7 @@ git clone repository.bundle restored | List heads | `libra bundle list-heads ` | `git bundle list-heads ` | | Import objects | `libra bundle unbundle ` | `git bundle unbundle ` | -Deferred surfaces are prerequisite/thin/incremental bundle creation and cloning -from a bundle through `libra clone`. `verify` checks checksum integrity but does +Deferred surfaces are prerequisite/thin/incremental bundle creation. +`libra clone ` reads a Git v2 bundle (directory first, then +`.bundle`, then ``). `verify` checks checksum integrity but does not build a temporary index to exhaustively decode every pack entry. diff --git a/docs/commands/clone.md b/docs/commands/clone.md index 20ad44053..19863414f 100644 --- a/docs/commands/clone.md +++ b/docs/commands/clone.md @@ -23,6 +23,14 @@ copied to matching `.libraignore` files so Libra ignore rules work immediately. For bare clones, no working tree checkout is performed and the repository directory itself becomes the object store. Bare clones do not create `.libraignore`. +A local Git v2 bundle is a valid source: after a repository directory is ruled +out, `clone` tries `.bundle` and then ``. The default destination +name drops a `.bundle` suffix. `HEAD` is taken from the bundle's `HEAD` line; +if that line is missing, the default branch is checked out when the bundle +contains it; otherwise no local branch is created. `--depth` on a plain-path +bundle is ignored with Git's local-clone warning. `remote.origin.url` records +the bundle's absolute path. + ## Global Config Schema Guard Configuration schema compatibility is role-scoped. Before `libra clone` trusts @@ -73,7 +81,7 @@ libra clone /path/to/local/repo ### `[LOCAL_PATH]` Optional destination directory. When omitted, Libra infers the directory name from the -repository URL (e.g., `repo` from `repo.git`). If inference fails, an error is returned +repository URL (e.g., `repo` from `repo.git` or `repo.bundle`). If inference fails, an error is returned asking the user to specify the path explicitly. ```bash diff --git a/docs/commands/zh-CN/bundle.md b/docs/commands/zh-CN/bundle.md index c4e9d197f..f98df124f 100644 --- a/docs/commands/zh-CN/bundle.md +++ b/docs/commands/zh-CN/bundle.md @@ -61,4 +61,4 @@ git clone repository.bundle restored | 列出 heads | `libra bundle list-heads ` | `git bundle list-heads ` | | 导入对象 | `libra bundle unbundle ` | `git bundle unbundle ` | -仍延后的 surface:prerequisite/thin/增量 bundle 创建,以及通过 `libra clone` 从 bundle 克隆。`verify` 会校验 checksum,但不会构建临时 index 来穷尽解码每个 pack entry。 +仍延后的 surface:prerequisite/thin/增量 bundle 创建。`libra clone ` 已支持读取 Git v2 bundle(先仓库目录,再 `.bundle`,再 ``)。`verify` 会校验 checksum,但不会构建临时 index 来穷尽解码每个 pack entry。 diff --git a/docs/commands/zh-CN/clone.md b/docs/commands/zh-CN/clone.md index f3fa3022f..b01339550 100644 --- a/docs/commands/zh-CN/clone.md +++ b/docs/commands/zh-CN/clone.md @@ -16,6 +16,8 @@ libra clone [OPTIONS] [LOCAL_PATH] 对于裸克隆,不会执行工作树检出,仓库目录本身会直接成为对象存储。裸克隆不会创建 `.libraignore`。 +本地 Git v2 bundle 可作为源:排除仓库目录后依次尝试 `.bundle` 与 ``。默认目标目录名去掉 `.bundle` 后缀。`HEAD` 取自 bundle 的 `HEAD` 行;没有该行时,若 bundle 含默认分支则检出它,否则不创建本地分支。普通路径上的 `--depth` 会按本地克隆警告忽略。`remote.origin.url` 记录 bundle 的绝对路径。 + ## 全局配置 Schema 保护 配置 schema 兼容性按角色判定。`libra clone` 在信任配置前,以只读方式检查 GlobalConfig 与 SystemConfig 元数据。真正的配置 future schema,或未注册/名称不匹配的迁移 receipt,在命令需要该作用域时以 `LBR-CONFIG-001` fail-closed。当前 manifest 已知的 Repository-only receipt(包括 `2026090801`)不会使配置库被误判为 future,受支持的配置值仍可读取。本 build 能识别 configuration-owned legacy-reader barrier;详见[配置兼容性](config.md#配置-schema-兼容性)。 @@ -42,7 +44,7 @@ libra clone /path/to/local/repo ### `[LOCAL_PATH]` -可选目标目录。省略时,Libra 会从仓库 URL 推断目录名(例如从 `repo.git` 推断 `repo`)。如果无法推断,会返回错误,要求用户显式指定路径。 +可选目标目录。省略时,Libra 会从仓库 URL 推断目录名(例如从 `repo.git` 或 `repo.bundle` 推断 `repo`)。如果无法推断,会返回错误,要求用户显式指定路径。 ```bash libra clone git@github.com:user/repo.git my-dir diff --git a/docs/development/commands/bundle.md b/docs/development/commands/bundle.md index 719db7a18..e4d0bbb0d 100644 --- a/docs/development/commands/bundle.md +++ b/docs/development/commands/bundle.md @@ -10,7 +10,7 @@ - create:explicit revisions + `--all`/`--branches`/`--tags`,完整 non-thin bundle。 - verify/list-heads:v2 header、prerequisite、PACK v2、完整 trailer checksum(list-heads 只读 header)。 - unbundle:验证并安装 pack/index,打印 heads,不更新 refs,与 Git unbundle 的消费边界一致。 -- 延后:prerequisite/thin/incremental range create、`libra clone `、verify 全 entry decode。 +- 延后:prerequisite/thin/incremental range create、verify 全 entry decode。 `libra clone ` 已实现(issues/474 CL-09)。 ## 设计方案 @@ -38,7 +38,7 @@ | 类别 | 未完成项 | 当前处理 | |---|---|---| | create | prerequisite/thin/`A..B` incremental | 延后;只写 full bundle。 | -| clone | Libra clone-from-bundle | 先 unbundle + explicit update-ref,或 system Git clone。 | +| clone | (已实现)`libra clone ` | 先判定仓库目录,再 `.bundle` / ``;HEAD 选择见 ADR-CL-06。 | | verify | exhaustive pack entry decode | checksum + version/prerequisite;unbundle 建 index 时执行更深验证。 | | scale | >1 GiB bundle | 明确拒绝。 | diff --git a/docs/development/plan/issues/474.md b/docs/development/plan/issues/474.md index b6acf7336..b87318051 100644 --- a/docs/development/plan/issues/474.md +++ b/docs/development/plan/issues/474.md @@ -1206,7 +1206,7 @@ ER-CL-02 接入 / 豁免清单(2026-09-23): **Task type:** `implementation` -**Lifecycle / Acceptance:** `pending` / 空 +**Lifecycle / Acceptance:** `in-progress` / `locally-accepted` **Description:** 按 ADR-CL-06 第 1、2 条,新增 bundle 源识别(仓库目录优先,再尝试 `.bundle` 与 ``)与 bundle 读取客户端,复用 `bundle.rs` 的校验与 pack 安装,按 bundle heads 建立跟踪 ref、tag 与检出分支。唯一行为轴是「从 bundle 文件克隆」。 @@ -1223,20 +1223,20 @@ ER-CL-02 接入 / 豁免清单(2026-09-23): **Acceptance criteria:** -- [ ] 判据矩阵 M-BUNDLE U1–U2(识别与不存在)。 -- [ ] `HEAD` 选择通过 M-BUNDLE U3–U4。 -- [ ] `--bare` 与 `--depth` 组合通过 M-BUNDLE U5(期望已写回 ADR-CL-01)。 -- [ ] 前置提交缺失通过 M-BUNDLE U6。 -- [ ] 损坏输入通过 M-BUNDLE U7,失败零残留(GC-CL-04)。 -- [ ] 远程配置与状态通过 M-BUNDLE U8。 -- [ ] ER-06a 同步:`docs/commands/clone.md`、`docs/commands/bundle.md` 与 zh-CN、`docs/development/commands/bundle.md:41` 改为已实现、`COMPATIBILITY.md:165`、`:207` 原行更新、网站 `clone.en.md`、`bundle.en.md`。 +- [x] 判据矩阵 M-BUNDLE U1–U2(识别与不存在)。 +- [x] `HEAD` 选择通过 M-BUNDLE U3–U4。 +- [x] `--bare` 与 `--depth` 组合通过 M-BUNDLE U5(期望已写回 ADR-CL-01)。 +- [x] 前置提交缺失通过 M-BUNDLE U6。 +- [x] 损坏输入通过 M-BUNDLE U7,失败零残留(GC-CL-04)。 +- [x] 远程配置与状态通过 M-BUNDLE U8。 +- [x] ER-06a 同步:`docs/commands/clone.md`、`docs/commands/bundle.md` 与 zh-CN、`docs/development/commands/bundle.md:41` 改为已实现、`COMPATIBILITY.md:165`、`:207` 原行更新、网站 `clone.en.md`、`bundle.en.md`。 **Verification:** -- [ ] `source .env.test && source .env.live-test && cargo nextest run --lib command::fetch`(new:bundle 源识别单测) -- [ ] `source .env.test && source .env.live-test && cargo nextest run --test command_test clone_test`(new:`test_clone_from_bundle_matrix`,含 `t5605:75-107` 移植) -- [ ] `source .env.test && source .env.live-test && cargo nextest run --test command_test bundle_test`(回归) -- [ ] `source .env.test && source .env.live-test && cargo nextest run --test compat_ledger_schema` +- [x] `source .env.test && source .env.live-test && cargo nextest run --lib command::fetch`(new:bundle 源识别单测) +- [x] `source .env.test && source .env.live-test && cargo nextest run --test command_test clone_test`(new:`test_clone_from_bundle_matrix`,含 `t5605:75-107` 移植) +- [x] `source .env.test && source .env.live-test && cargo nextest run --test command_test bundle_test`(回归) +- [x] `source .env.test && source .env.live-test && cargo nextest run --test compat_ledger_schema` **Full-suite trigger:** `T-1: 在共享的 remote 源识别(fetch::discover_remote)中新增源类型` diff --git a/docs/development/plan/plan-status.md b/docs/development/plan/plan-status.md index be38c3d37..931313d39 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` 重跑排队中);CL-04..CL-08 `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-09 `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 | @@ -183,7 +183,8 @@ | **CL-06 普通路径忽略浅化参数** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-05 发布窗口)** | | **CL-07 shallow Git 源克隆** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-06 发布窗口)** | | **CL-08 bundle create 写入 HEAD** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-07 发布窗口)** | -| CL-09..CL-15 | `pending` | 未 bump | +| **CL-09 clone 接受 bundle 源** | **`in-progress`/`locally-accepted`** | **未 bump(等 CL-08 发布窗口)** | +| CL-10..CL-15 | `pending` | 未 bump | ### 3.6 issues/476(工作树命令族) @@ -225,7 +226,7 @@ SBX-01..05 `done/locally-accepted`;**发布步按 DEFER-SBX-06 正式延后** - **当前正在执行:** `plan-20260907` B3-00(发布者:本会话执行该卡的 Agent)。`plan-20260925` SCAP-01 已 `remote-pending`(PR #519 / `v0.23.55`,D 组异步)。 - **SCAP-01 与 B3-00:** SCAP-01 的 C 组已落地,`runtime.rs` 写集互斥放开;B3-00 可开工。 -- **下一步(SCAP D 组落地后):** 把 SCAP-01 / SCAP-02 标成 `done` / `complete`。`issues/474` 的 `CL-04`..`CL-08` 仍按窗口排队。 +- **下一步(SCAP D 组落地后):** 把 SCAP-01 / SCAP-02 标成 `done` / `complete`。`issues/474` 的 `CL-09`..`CL-15` 正按窗口发布(不等待 D 组 CDN)。 - **并行窗口(不在本执行指针):** `issues/476` WT-03 仍等 DEP-WT-08;`plan-20260918` 其余 add 卡、`plan-20260819` M2 仍登记为实施中,但不抢本卡的 `fsck.rs` 写集。 --- diff --git a/install.ps1 b/install.ps1 index c8d2883dc..52aed406b 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.55" +$DefaultVersion = "v0.23.56" # 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 9aac05989..64f914a02 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.55" +DEFAULT_VERSION="v0.23.56" # 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 diff --git a/src/command/bundle.rs b/src/command/bundle.rs index d6b44f528..c0b44643b 100644 --- a/src/command/bundle.rs +++ b/src/command/bundle.rs @@ -557,11 +557,12 @@ async fn encode_pack(entries: Vec) -> CliResult> { // ---------------------------------------------------------------------------- /// The parsed text header of a bundle. -struct BundleHeader { - prerequisites: Vec<(String, String)>, - heads: Vec<(String, String)>, +#[derive(Debug, Clone)] +pub(crate) struct BundleHeader { + pub(crate) prerequisites: Vec<(String, String)>, + pub(crate) heads: Vec<(String, String)>, /// Byte offset where the pack begins (just after the blank line). - pack_offset: usize, + pub(crate) pack_offset: usize, } fn verify(file: &Path) -> CliResult<()> { @@ -747,7 +748,7 @@ fn files_equal(left: &Path, right: &Path) -> std::io::Result { } } -fn read_bundle_bounded(file: &Path, exit_code: i32) -> CliResult> { +pub(crate) fn read_bundle_bounded(file: &Path, exit_code: i32) -> CliResult> { let input = fs::File::open(file).map_err(|error| read_err(error, exit_code))?; let size = input .metadata() @@ -779,7 +780,7 @@ fn read_bundle_bounded(file: &Path, exit_code: i32) -> CliResult> { Ok(bytes) } -fn verify_prerequisites(header: &BundleHeader, exit_code: i32) -> CliResult<()> { +pub(crate) fn verify_prerequisites(header: &BundleHeader, exit_code: i32) -> CliResult<()> { let storage = util::objects_storage(); let mut missing = Vec::new(); for (oid, _) in &header.prerequisites { @@ -799,7 +800,7 @@ fn verify_prerequisites(header: &BundleHeader, exit_code: i32) -> CliResult<()> .with_stable_code(StableErrorCode::CliInvalidTarget)) } -fn validate_bundle_pack(pack: &[u8], exit_code: i32) -> CliResult { +pub(crate) fn validate_bundle_pack(pack: &[u8], exit_code: i32) -> CliResult { let hash_len = get_hash_kind().size(); if pack.len() < 12 + hash_len || &pack[0..4] != b"PACK" || pack[4..8] != [0, 0, 0, 2] { return Err( @@ -828,7 +829,7 @@ fn validate_bundle_pack(pack: &[u8], exit_code: i32) -> CliResult { } /// Parse the text header up to the blank line that precedes the pack. -fn parse_header(bytes: &[u8], exit_code: i32) -> CliResult { +pub(crate) fn parse_header(bytes: &[u8], exit_code: i32) -> CliResult { // A malformed bundle is a verification failure (exit 1), matching // `git bundle verify` — exit 128 is reserved for usage errors. let invalid = |message: &str| { diff --git a/src/command/clone.rs b/src/command/clone.rs index 0a879f88b..aed14e785 100644 --- a/src/command/clone.rs +++ b/src/command/clone.rs @@ -309,12 +309,41 @@ fn remote_spec_is_plain_local_path(spec: &str) -> bool { true } +/// Git `git_url_basename`: last path component, then drop `.git` / `.bundle`. +fn clone_url_basename(url: &str) -> Option { + let url = url.trim_end_matches('/'); + if url.is_empty() { + return None; + } + let name = url.rsplit_once('/').map(|(_, name)| name).unwrap_or(url); + let name = name.strip_suffix(".git").unwrap_or(name); + let name = name.strip_suffix(".bundle").unwrap_or(name); + if name.is_empty() || name == "." || name == ".." { + return None; + } + Some(name.to_string()) +} + +/// Default destination when the user omitted `[LOCAL_PATH]`. +/// Bare/mirror clones append `.git` (ADR-CL-06). +fn inferred_clone_destination(url: &str, bare: bool) -> Option { + let name = clone_url_basename(url)?; + if bare { + Some(format!("{name}.git")) + } else { + Some(name) + } +} + /// A plain filesystem Git path uses Git's local-clone rules: `--depth`, /// `--shallow-since`, `--shallow-exclude`, and `--filter` are ignored. /// `file://` and `--no-local` keep transport semantics. A local Libra source /// stays on the fail-closed path (D20). fn uses_local_clone_semantics(args: &CloneArgs, remote_client: &fetch::RemoteClient) -> bool { match remote_client { + fetch::RemoteClient::Bundle(_) => { + remote_spec_is_plain_local_path(&args.remote_repo) && !args.no_local + } fetch::RemoteClient::Local(client) if !client.is_libra_source() => { // A shallow Git source must use the transport so its `.git/shallow` // boundaries are copied (git `builtin/clone.c:1333-1340`). @@ -1164,7 +1193,7 @@ async fn execute_clone_inner( let local_path = match &args.local_path { Some(path) => path.clone(), None => { - let repo_name = util::get_repo_name_from_url(&remote_repo) + let repo_name = inferred_clone_destination(&args.remote_repo, args.bare) .ok_or((CloneError::CannotInferDestination, None))?; original_dir.join(repo_name).to_string_lossy().into_owned() } @@ -1541,11 +1570,16 @@ async fn clone_into_destination( eprintln!("Checking out working copy ..."); } + let advertised_head = discovery + .refs + .iter() + .any(|reference| reference._ref == "HEAD"); let setup_result = setup_repository( remote_config.clone(), fetch_branch, !args.bare && !args.no_checkout, single_branch, + advertised_head, ) .await?; @@ -1770,16 +1804,18 @@ pub(crate) async fn setup_repository( specified_branch: Option, checkout_worktree: bool, single_branch: bool, + advertised_head: bool, ) -> Result { let db = get_db_conn_instance().await; let remote_head = Head::remote_current_with_conn(&db, &remote_config.name).await; let branch_to_checkout = match specified_branch { Some(branch_name) => Some(branch_name), - None => match remote_head { + None if advertised_head => match remote_head { Some(Head::Branch(name)) => Some(name), - _ => None, + _ => default_tracked_branch(&db, &remote_config.name).await?, }, + None => default_tracked_branch(&db, &remote_config.name).await?, }; if let Some(branch_name) = branch_to_checkout { @@ -1899,20 +1935,30 @@ pub(crate) async fn setup_repository( ) .await; - let default_branch = "main"; - let merge_ref = format!("refs/heads/{}", default_branch); - let _ = ConfigKv::set(&format!("branch.{default_branch}.merge"), &merge_ref, false).await; - let _ = ConfigKv::set( - &format!("branch.{default_branch}.remote"), - &remote_config.name, - false, - ) - .await; - Ok(SetupResult { branch_name: None }) } } +/// When the remote advertised no HEAD, check out `main`/`master` only if that +/// tracking ref exists (M-BUNDLE U3). Otherwise leave the clone without a +/// local branch (U4). +async fn default_tracked_branch( + db: &sea_orm::DatabaseConnection, + remote: &str, +) -> Result, CloneError> { + for name in ["main", "master"] { + let tracking = format!("refs/remotes/{remote}/{name}"); + if Branch::find_branch_result_with_conn(db, &tracking, Some(remote)) + .await + .map_err(|source| CloneError::LocalBranchState { source })? + .is_some() + { + return Ok(Some(name.to_string())); + } + } + Ok(None) +} + /// Unit tests for the clone module /// Unit tests for the clone module #[cfg(test)] @@ -1923,6 +1969,32 @@ mod tests { use super::*; use crate::utils::test::{ChangeDirGuard, ScopedEnvVar}; + #[test] + fn inferred_clone_destination_follows_git_url_basename() { + assert_eq!( + inferred_clone_destination("/tmp/gdeep", false).as_deref(), + Some("gdeep") + ); + assert_eq!( + inferred_clone_destination("/tmp/gdeep", true).as_deref(), + Some("gdeep.git") + ); + assert_eq!( + inferred_clone_destination("/tmp/gdeep.git/", true).as_deref(), + Some("gdeep.git") + ); + assert_eq!( + inferred_clone_destination("/tmp/b1.bundle", false).as_deref(), + Some("b1") + ); + assert_eq!( + inferred_clone_destination("/tmp/b1.bundle", true).as_deref(), + Some("b1.git") + ); + assert_eq!(inferred_clone_destination("/tmp/..", false), None); + assert_eq!(inferred_clone_destination("/tmp/.git", true), None); + } + #[test] fn discover_remote_unauthorized_maps_to_auth_permission_denied() { let cli = map_discover_remote_error(fetch::FetchError::Discovery { diff --git a/src/command/fetch.rs b/src/command/fetch.rs index 3965485d3..04847dbf7 100644 --- a/src/command/fetch.rs +++ b/src/command/fetch.rs @@ -46,6 +46,7 @@ use crate::{ model::reference as ref_model, protocol::{ DiscRef, DiscoveryResult, FetchStream, ProtocolClient, + bundle_client::BundleClient, git_client::GitClient, https_client::HttpsClient, local_client::LocalClient, @@ -86,6 +87,7 @@ pub(crate) enum RemoteClient { Local(LocalClient), Git(GitClient), Ssh(SshClient), + Bundle(BundleClient), } impl RemoteClient { @@ -114,9 +116,7 @@ impl RemoteClient { let path = url .to_file_path() .map_err(|_| format!("invalid file url: {spec}"))?; - let client = LocalClient::from_path(path) - .map_err(|e| format!("invalid local repository '{}': {}", spec, e))?; - Ok(Self::Local(client)) + local_or_bundle_client(path, spec) } "git" => { if url.host_str().is_none() { @@ -138,12 +138,32 @@ impl RemoteClient { } else { normalized }; - let client = LocalClient::from_path(normalized) - .map_err(|e| format!("invalid local repository '{}': {}", spec, e))?; - Ok(Self::Local(client)) + local_or_bundle_client(PathBuf::from(normalized), spec) } } +} + +/// A local filesystem spec is a Git/Libra repo first; otherwise a bundle file +/// (`.bundle`, then ``), matching git clone's recognition order. +fn local_or_bundle_client(path: PathBuf, spec: &str) -> Result { + match LocalClient::from_path(&path) { + Ok(client) => Ok(RemoteClient::Local(client)), + Err(error) => match BundleClient::open_resolved(&path) { + Ok(client) => Ok(RemoteClient::Bundle(client)), + Err(bundle_error) => { + if looks_like_missing_bundle(&path, spec) { + Err(format!("bundle file does not exist: {spec}")) + } else if path.is_file() { + Err(format!("invalid local repository '{spec}': {bundle_error}")) + } else { + Err(format!("invalid local repository '{spec}': {error}")) + } + } + }, + } +} +impl RemoteClient { pub(crate) fn with_network_timeouts( self, connect_timeout: Duration, @@ -168,7 +188,7 @@ impl RemoteClient { self, remote: Option<&str>, ) -> Result { - let is_local = matches!(self, Self::Local(_)); + let is_local = matches!(self, Self::Local(_) | Self::Bundle(_)); if is_local { return Ok(self); } @@ -211,6 +231,7 @@ impl RemoteClient { RemoteClient::Local(client) => client.discovery_reference(service).await, RemoteClient::Git(client) => client.discovery_reference(service).await, RemoteClient::Ssh(client) => client.discovery_reference(service).await, + RemoteClient::Bundle(client) => client.discovery_reference(service).await, } } @@ -226,6 +247,7 @@ impl RemoteClient { RemoteClient::Local(client) => client.fetch_objects(have, want, shallow, depth).await, RemoteClient::Git(client) => client.fetch_objects(have, want, shallow, depth).await, RemoteClient::Ssh(client) => client.fetch_objects(have, want, shallow, depth).await, + RemoteClient::Bundle(client) => client.fetch_objects(have, want, shallow, depth).await, } } } @@ -1407,7 +1429,27 @@ pub(crate) async fn discover_remote_with_name( /// Classify a remote-spec construction failure into a typed kind and a /// human-readable reason string. +fn looks_like_missing_bundle(path: &Path, spec: &str) -> bool { + let mut with_suffix = path.as_os_str().to_os_string(); + with_suffix.push(".bundle"); + let with_suffix = PathBuf::from(with_suffix); + let named_bundle = + path.extension().is_some_and(|ext| ext == "bundle") || spec.contains(".bundle"); + named_bundle && !path.is_file() && !with_suffix.is_file() +} + fn classify_remote_spec_error(remote_spec: &str, message: &str) -> (RemoteSpecErrorKind, String) { + if message.starts_with("bundle file does not exist") { + let display = if remote_spec == "/" { + "/".to_string() + } else { + remote_spec.trim_end_matches('/').to_string() + }; + return ( + RemoteSpecErrorKind::MissingLocalRepo, + format!("bundle '{display}' does not exist"), + ); + } if message.starts_with("invalid local repository") { let display = if remote_spec == "/" { "/".to_string() @@ -1791,6 +1833,7 @@ pub(crate) fn normalize_remote_url(remote_input: &str, remote_client: &RemoteCli remote_input.to_string() } RemoteClient::Local(client) => client.repo_path().to_string_lossy().to_string(), + RemoteClient::Bundle(client) => client.path().to_string_lossy().to_string(), } } @@ -5391,4 +5434,25 @@ mod tests { let entry = [0x50_u8]; // 0b0_101_0000 = type 5 assert_eq!(parse_pack_entry_data_offset(&entry, 0, 20), None); } + + #[test] + fn missing_bundle_path_is_an_invalid_local_repository() { + use super::{RemoteSpecErrorKind, classify_remote_spec_error, local_or_bundle_client}; + + let missing = std::env::temp_dir().join("libra-missing-b4.bundle"); + let error = match local_or_bundle_client(missing.clone(), missing.to_str().unwrap()) { + Ok(_) => panic!("missing bundle must not open"), + Err(error) => error, + }; + assert!( + error.contains("bundle file does not exist"), + "unexpected spec error: {error}" + ); + let (kind, reason) = classify_remote_spec_error(missing.to_str().unwrap(), &error); + assert_eq!(kind, RemoteSpecErrorKind::MissingLocalRepo); + assert!( + reason.contains("does not exist"), + "U2 must report repository does not exist: {reason}" + ); + } } diff --git a/src/internal/protocol/bundle_client.rs b/src/internal/protocol/bundle_client.rs new file mode 100644 index 000000000..177ed69ea --- /dev/null +++ b/src/internal/protocol/bundle_client.rs @@ -0,0 +1,187 @@ +//! Bundle-file transport: treat a Git v2 bundle as a fetch source. +//! +//! Recognition order (git `builtin/clone.c:97-142`): a local repository +//! directory wins; then `.bundle`; then `` as a bundle file. + +use std::{ + io::Error as IoError, + path::{Path, PathBuf}, + str::FromStr, +}; + +use bytes::Bytes; +use futures_util::stream; +use git_internal::{ + errors::GitError, + hash::{HashKind, ObjectHash}, +}; + +use super::{DiscRef, DiscoveryResult, FetchStream}; +use crate::{ + command::bundle::{ + BundleHeader, parse_header, read_bundle_bounded, validate_bundle_pack, verify_prerequisites, + }, + git_protocol::ServiceType, + utils::util::cur_dir, +}; + +/// A parsed, bounded Git v2 bundle that can advertise heads and emit its pack. +#[derive(Debug, Clone)] +pub struct BundleClient { + path: PathBuf, + bytes: Vec, + header: BundleHeader, +} + +/// After a path fails as a repository directory, try `.bundle` then ``. +pub fn resolve_bundle_file(path: &Path) -> Option { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cur_dir().join(path) + }; + let mut with_suffix = absolute.clone().into_os_string(); + with_suffix.push(".bundle"); + let with_suffix = PathBuf::from(with_suffix); + if with_suffix.is_file() { + return Some(with_suffix); + } + if absolute.is_file() { + return Some(absolute); + } + None +} + +impl BundleClient { + /// Open `path` as a bundle, or the Git-style `.bundle` fallback. + pub fn open_resolved(path: impl AsRef) -> Result { + let file = resolve_bundle_file(path.as_ref()) + .ok_or_else(|| format!("bundle file does not exist: {}", path.as_ref().display()))?; + Self::open(file) + } + + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cur_dir().join(path) + }; + let bytes = read_bundle_bounded(&absolute, 128).map_err(|error| error.to_string())?; + let header = parse_header(&bytes, 128).map_err(|error| error.to_string())?; + Ok(Self { + path: absolute, + bytes, + header, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn discovery_reference( + &self, + _service: ServiceType, + ) -> Result { + let hash_kind = hash_kind_from_heads(&self.header.heads)?; + let format = match hash_kind { + HashKind::Sha1 => "sha1", + HashKind::Sha256 => "sha256", + HashKind::Blake3 => "blake3", + }; + let refs = self + .header + .heads + .iter() + .map(|(oid, name)| DiscRef { + _hash: oid.clone(), + _ref: name.clone(), + }) + .collect(); + Ok(DiscoveryResult { + refs, + capabilities: vec![format!("object-format={format}")], + hash_kind, + }) + } + + pub async fn fetch_objects( + &self, + _have: &[String], + _want: &[String], + _shallow: &[String], + _depth: Option, + ) -> Result { + verify_prerequisites(&self.header, 128) + .map_err(|error| IoError::other(error.to_string()))?; + let pack = &self.bytes[self.header.pack_offset..]; + validate_bundle_pack(pack, 128).map_err(|error| IoError::other(error.to_string()))?; + Ok(pack_bytes_to_fetch_stream(pack.to_vec())) + } +} + +fn hash_kind_from_heads(heads: &[(String, String)]) -> Result { + let Some((oid, _)) = heads.first() else { + return Err(GitError::NetworkError( + "bundle advertises no heads".to_string(), + )); + }; + match ObjectHash::from_str(oid) { + Ok(_) if oid.len() == 40 => Ok(HashKind::Sha1), + Ok(_) if oid.len() == 64 => Ok(HashKind::Sha256), + Ok(_) => Err(GitError::NetworkError(format!( + "unsupported bundle object-id length {}", + oid.len() + ))), + Err(error) => Err(GitError::NetworkError(format!( + "bundle head has an invalid object id '{oid}': {error}" + ))), + } +} + +fn pack_bytes_to_fetch_stream(pack_data: Vec) -> FetchStream { + let mut response_data = Vec::new(); + let nak_line = "NAK\n"; + let nak_len_hex = format!("{:04x}", nak_line.len() + 4); + response_data.extend_from_slice(nak_len_hex.as_bytes()); + response_data.extend_from_slice(nak_line.as_bytes()); + + let chunk_size = 65500; + for chunk in pack_data.chunks(chunk_size) { + let mut sideband_data = Vec::with_capacity(1 + chunk.len()); + sideband_data.push(1); + sideband_data.extend_from_slice(chunk); + let len_hex = format!("{:04x}", sideband_data.len() + 4); + response_data.extend_from_slice(len_hex.as_bytes()); + response_data.extend_from_slice(&sideband_data); + } + response_data.extend_from_slice(b"0000"); + Box::pin(stream::iter(vec![Ok(Bytes::from(response_data))])) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn resolve_bundle_file_prefers_suffix_then_exact_file() { + let dir = tempdir().unwrap(); + let named = dir.path().join("b3"); + let suffixed = dir.path().join("b3.bundle"); + std::fs::write(&suffixed, b"# v2 git bundle\n").unwrap(); + assert_eq!(resolve_bundle_file(&named), Some(suffixed.clone())); + assert_eq!(resolve_bundle_file(&suffixed), Some(suffixed)); + assert_eq!(resolve_bundle_file(&dir.path().join("missing")), None); + } + + #[test] + fn resolve_bundle_file_accepts_an_exact_bundle_path() { + let dir = tempdir().unwrap(); + let bundle = dir.path().join("repo.bundle"); + std::fs::write(&bundle, b"# v2 git bundle\n").unwrap(); + assert_eq!(resolve_bundle_file(&bundle), Some(bundle)); + } +} diff --git a/src/internal/protocol/mod.rs b/src/internal/protocol/mod.rs index ad73b2fab..94aa8be5a 100644 --- a/src/internal/protocol/mod.rs +++ b/src/internal/protocol/mod.rs @@ -16,6 +16,7 @@ use crate::{ internal::branch::Branch, }; +pub mod bundle_client; pub mod git_client; // to support git server protocol (git://) over TCP pub mod https_client; pub mod lfs_client; diff --git a/src/utils/convert.rs b/src/utils/convert.rs index 5355b6e47..ab8b774ef 100644 --- a/src/utils/convert.rs +++ b/src/utils/convert.rs @@ -171,7 +171,7 @@ pub async fn convert_from_git_repository( }); } - clone::setup_repository(remote, None, !is_bare, false) + clone::setup_repository(remote, None, !is_bare, false, false) .await .map(|_| ()) // discard SetupResult; convert only needs success/failure .map_err(|error| InitError::ConversionFailed { diff --git a/tests/command/clone_test.rs b/tests/command/clone_test.rs index ba1de879e..a976e721c 100644 --- a/tests/command/clone_test.rs +++ b/tests/command/clone_test.rs @@ -2109,3 +2109,196 @@ fn test_clone_reject_shallow_local_git_source() { ); assert!(!r5.join(".libra").exists(), "R5 must not leave a target"); } + +/// M-BUNDLE U1–U8: clone from a Libra-created Git v2 bundle. +#[test] +fn test_clone_from_bundle_matrix() { + use super::{assert_cli_success, create_committed_repo_via_cli, run_libra_command}; + + let src = create_committed_repo_via_cli(); + assert_cli_success( + &run_libra_command(&["branch", "dev"], src.path()), + "branch dev", + ); + assert_cli_success(&run_libra_command(&["tag", "v1"], src.path()), "tag v1"); + let parent = tempfile::tempdir().unwrap(); + + let b1 = parent.path().join("b1.bundle"); + assert_cli_success( + &run_libra_command( + &["bundle", "create", b1.to_str().unwrap(), "--all"], + src.path(), + ), + "U1 create --all", + ); + let u1 = run_libra_command(&["clone", b1.to_str().unwrap()], parent.path()); + assert_cli_success(&u1, "U1 clone b1.bundle"); + let u1_dest = parent.path().join("b1"); + assert!(u1_dest.join(".libra").exists(), "U1 dest drops .bundle"); + assert!(u1_dest.join("tracked.txt").exists(), "U1 checks out HEAD"); + + let nested = parent.path().join("dir"); + fs::create_dir_all(&nested).unwrap(); + let b3 = nested.join("b3.bundle"); + fs::copy(&b1, &b3).unwrap(); + let u1b = run_libra_command( + &["clone", nested.join("b3").to_str().unwrap()], + parent.path(), + ); + assert_cli_success(&u1b, "U1 clone dir/b3"); + assert!(parent.path().join("b3").join(".libra").exists()); + + let missing = parent.path().join("b4.bundle"); + let u2 = run_libra_command(&["clone", missing.to_str().unwrap()], parent.path()); + assert!(!u2.status.success(), "U2 missing bundle"); + let u2_err = format!( + "{}{}", + String::from_utf8_lossy(&u2.stdout), + String::from_utf8_lossy(&u2.stderr) + ); + assert!( + u2_err.contains("does not exist"), + "U2 must say repository does not exist: {u2_err}" + ); + + let main_only = parent.path().join("main-only.bundle"); + assert_cli_success( + &run_libra_command( + &["bundle", "create", main_only.to_str().unwrap(), "main"], + src.path(), + ), + "U3 create main", + ); + let u3_dest = parent.path().join("u3"); + assert_cli_success( + &run_libra_command( + &[ + "clone", + main_only.to_str().unwrap(), + u3_dest.to_str().unwrap(), + ], + parent.path(), + ), + "U3 clone without HEAD", + ); + assert!(u3_dest.join("tracked.txt").exists(), "U3 checks out main"); + + let dev_only = parent.path().join("dev-only.bundle"); + assert_cli_success( + &run_libra_command( + &["bundle", "create", dev_only.to_str().unwrap(), "dev"], + src.path(), + ), + "U4 create dev", + ); + let u4_dest = parent.path().join("u4"); + let u4 = run_libra_command( + &[ + "clone", + dev_only.to_str().unwrap(), + u4_dest.to_str().unwrap(), + ], + parent.path(), + ); + assert_cli_success(&u4, "U4 clone without default branch"); + assert!( + !u4_dest.join("tracked.txt").exists(), + "U4 must not check out a local branch" + ); + + let bare_dest = parent.path().join("bare-from-bundle"); + assert_cli_success( + &run_libra_command( + &[ + "clone", + "--bare", + b1.to_str().unwrap(), + bare_dest.to_str().unwrap(), + ], + parent.path(), + ), + "U5 clone --bare", + ); + assert!(bare_dest.join("libra.db").exists(), "U5 bare dest"); + assert!( + !bare_dest.join("tracked.txt").exists(), + "U5 bare has no worktree" + ); + + let depth_dest = parent.path().join("depth-from-bundle"); + let depth = run_libra_command( + &[ + "clone", + "--depth", + "1", + b1.to_str().unwrap(), + depth_dest.to_str().unwrap(), + ], + parent.path(), + ); + assert_cli_success(&depth, "U5 clone --depth 1"); + let depth_text = format!( + "{}{}", + String::from_utf8_lossy(&depth.stdout), + String::from_utf8_lossy(&depth.stderr) + ); + assert!( + depth_text.contains("--depth is ignored in local clones"), + "U5 must warn that --depth is ignored: {depth_text}" + ); + assert!(depth_dest.join("tracked.txt").exists()); + + let incremental = parent.path().join("incremental.bundle"); + let bytes = fs::read(&main_only).unwrap(); + let header_end = bytes.windows(2).position(|w| w == b"\n\n").unwrap(); + let mut inc = Vec::from(&bytes[..header_end + 1]); + inc.extend_from_slice(b"-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa missing\n"); + inc.extend_from_slice(&bytes[header_end + 1..]); + fs::write(&incremental, inc).unwrap(); + let u6_dest = parent.path().join("u6"); + let u6 = run_libra_command( + &[ + "clone", + incremental.to_str().unwrap(), + u6_dest.to_str().unwrap(), + ], + parent.path(), + ); + assert!(!u6.status.success(), "U6 missing prerequisite"); + assert!( + !u6_dest.join(".libra").exists(), + "U6 must not leave a target" + ); + + let corrupt = parent.path().join("corrupt.bundle"); + fs::write(&corrupt, b"this is not a bundle\n").unwrap(); + let u7_dest = parent.path().join("u7"); + let u7 = run_libra_command( + &[ + "clone", + corrupt.to_str().unwrap(), + u7_dest.to_str().unwrap(), + ], + parent.path(), + ); + assert!(!u7.status.success(), "U7 corrupt bundle"); + assert!( + !u7_dest.join(".libra").exists(), + "U7 must not leave a target" + ); + + let url = run_libra_command(&["config", "get", "remote.origin.url"], &u1_dest); + assert_cli_success(&url, "U8 remote.origin.url"); + let origin = String::from_utf8_lossy(&url.stdout); + assert!( + origin.contains("b1.bundle"), + "U8 url must be the bundle path: {origin}" + ); + let status = run_libra_command(&["status", "--short", "--branch"], &u1_dest); + assert_cli_success(&status, "U8 status"); + let status_text = String::from_utf8_lossy(&status.stdout); + assert!( + status_text.contains("origin/"), + "U8 status should show origin tracking: {status_text}" + ); +}