git-ents.gitmain
⌘K
foforge
commit 7c78815
multi: add gix-ref-store crate (RefStore read/CAS split, loose-ref impl)

Phase 1 per docs/development-plan.adoc. Implements arch.refstore-read-cas-split (RefStoreRead vs RefStore, so the gate can depend on reads only) and arch.loose-cas-discipline (LooseRefStore writes through gitoxide’s own in-process ref transaction, never a git update-ref subprocess) from overview.sdoc.

The CAS conformance suite (tests/conformance.rs) that stands in for the Phase 1 → 2 gate exposed a real gap in the pinned gitoxide version: the file-transaction precondition check reads a ref’s current value before acquiring that ref’s own lock file, so two independent gix::Repository handles racing the same ref (two processes, concretely) can both observe the same stale precondition and both "win" a create or update. Closing that is exactly what "its own compare-and-swap discipline" asks for, so LooseRefStore::transaction now holds a store-level lock (a .lock file distinct from any ref’s own, acquired via gix-lock) around the whole read-check-write sequence before ever calling into gitoxide, rather than trusting gitoxide’s internal ordering alone. Costs cross-ref concurrency (one lock serializes all refs in a repository) in exchange for actual correctness; a per-ref-set lock is a straightforward follow-up if the cost matters in practice.

facet-git-tree needed no phase-1 work: it already lives as an external crate (git dependency on github.com/git-ents/facet-git-tree), the #[facet(transparent)] fix already landed upstream (a910057), and the workspace Cargo.toml already has no local [patch] section to remove — verified rather than redone.

config/nextest.toml’s docker test-group filter named four pre-redo

crates (refstore-postgres, effect-dispatcher, git-ents-server, git-effect) that don’t exist yet in this workspace; a filter naming an absent package is a hard nextest config error, so it’s trimmed back to just the group definition until those crates land in their own phases.

Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = [] +members = ["crates/gix-ref-store"] [workspace.package] edition = "2024" @@ -48,6 +48,7 @@ gix-date = "0.15" gix-features = { version = "0.48", features = ["zlib"] } gix-hash = { version = "0.25", features = ["sha1"] } +gix-lock = "23.0" gix-object = "0.61" gix-odb = "0.81" gix-pack = "0.71"
.config/nextest.toml @@ -5,15 +5,12 @@ # backend) race and flake when several test binaries hit the Docker daemon # at once at default parallelism. Serialize exactly those tests through # one test group; everything else keeps full parallelism. +# +# The `docker` group and its filter are reinstated as the crates that need +# it (refstore-postgres, effect-dispatcher, git-ents-server, git-effect) +# land in their own phases; none exist yet in the post-redo workspace, and +# a filter naming an absent package is a hard nextest config error, not a +# no-op. [test-groups] docker = { max-threads = 1 } - -[[profile.default.overrides]] -filter = ''' -package(refstore-postgres) -| package(effect-dispatcher) -| (package(git-ents-server) & binary(hydrate)) -| (package(git-effect) & test(docker_backend_runs_a_trivial_effect)) -''' -test-group = "docker"
Cargo.lock @@ -1,0 +1,1952 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytesize" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gix" +version = "0.84.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "parking_lot", + "regex", + "signal-hook", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16909cacc78936ab96f6c3be08379d0a2e88bfa3a7527972d2ed75c7517ef31e" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d39a0c14af94c2edaa5eefe06d5ef2cdea55316ae9a9321314288e3f55fa4c0" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-chunk" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-credentials" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40cd22f0dd71988be12d6e78b1709de2370e1957c5f107ff31e56caeba3745d" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21bb2a53a6fd917ec499ed0bfb5b6887de7a15bd79197dcea7c987938749a9f1" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "thiserror", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-mailmap" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-negotiate" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890c936a215bae25818c076cb881cb2e54d2c66ba947ba58b8dd47cff921bf55" +dependencies = [ + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.71.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror", +] + +[[package]] +name = "gix-prompt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee604d7746080ae7e1023bf47204bcc2c5f307bfbe2306a3c90b1bfd1a2c6d8" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-ref-store" +version = "0.0.0" +dependencies = [ + "gix", + "gix-hash", + "gix-lock", + "rstest", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +dependencies = [ + "bitflags 2.13.0", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-status" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22042e385d28a34275e029d98f4970285045be14b9073658ca897923f2ed8700" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "gix-submodule" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" + +[[package]] +name = "gix-transport" +version = "0.57.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-traverse" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" +dependencies = [ + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-url" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" +dependencies = [ + "bstr", + "gix-path", + "percent-encoding", + "thiserror", +] + +[[package]] +name = "gix-utils" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" +dependencies = [ + "bstr", + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-state" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bffae8b3ca258fdd50370cd51f06deb4c76a3b43db3868bc28dde45ffa77d69" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
crates/gix-ref-store/Cargo.toml @@ -1,0 +1,19 @@ +[package] +name = "gix-ref-store" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +gix = { workspace = true } +gix-hash = { workspace = true } +gix-lock = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +rstest = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true
crates/gix-ref-store/src/edit.rs @@ -1,0 +1,95 @@ +//! The vocabulary of a [`crate::RefStore::transaction`] call: what a +//! [`RefEdit`] expects a ref to hold, what a batch of them can do +//! atomically, and how the store reports which one failed. + +use gix::refs::FullName; +use gix_hash::ObjectId; + +/// The compare-and-swap precondition a [`RefEdit`] requires of a ref's +/// current value before the edit is allowed to apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Expected { + /// No requirement: set unconditionally. + Any, + /// The ref must not currently exist. + MustNotExist, + /// The ref must currently exist and equal the given object id. + MustExistAndMatch(ObjectId), +} + +/// One ref's half of a [`crate::RefStore::transaction`] batch: what `name` +/// is expected to hold, and what it should become. `new: None` deletes the +/// ref. +/// +/// # Examples +/// +/// ``` +/// use gix_hash::ObjectId; +/// use gix_ref_store::{Expected, RefEdit}; +/// +/// let oid = ObjectId::null(gix_hash::Kind::Sha1); +/// let edit = RefEdit { +/// name: "refs/meta/config".try_into().expect("valid refname"), +/// expected: Expected::MustNotExist, +/// new: Some(oid), +/// }; +/// assert_eq!(edit.new, Some(oid)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefEdit { + /// The ref this edit applies to. + pub name: FullName, + /// The compare-and-swap precondition checked against `name`'s current + /// value before the edit applies. + pub expected: Expected, + /// The value to set `name` to, or `None` to delete it. + pub new: Option<ObjectId>, +} + +/// The result of a [`crate::RefStore::transaction`] call that itself +/// completed (returned `Ok`): either every edit applied, or none did. +/// +/// A `Rejected` outcome is not an [`crate::Error`] — a stale +/// compare-and-swap precondition is an expected, checkable result, not a +/// backend fault. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TxOutcome { + /// Every edit in the batch applied atomically. + Applied, + /// The transaction did not apply: `name`'s current value did not match + /// its edit's [`Expected`] precondition. No edit in the batch took + /// effect — compare-and-swap is all-or-nothing, per the trait's + /// contract. + Rejected { + /// The first ref whose precondition failed. + name: FullName, + }, +} + +/// An iterator over `(name, tip)` pairs from a +/// [`crate::RefStoreRead::iter_prefix`] query, wrapping whatever iterator +/// the backend produces so the trait itself stays object-safe. +pub struct RefIter(Box<dyn Iterator<Item = crate::Result<(FullName, ObjectId)>> + Send>); + +impl RefIter { + /// Wrap `iter` as a [`RefIter`]. + pub fn new( + iter: impl Iterator<Item = crate::Result<(FullName, ObjectId)>> + Send + 'static, + ) -> Self { + Self(Box::new(iter)) + } +} + +impl Iterator for RefIter { + type Item = crate::Result<(FullName, ObjectId)>; + + fn next(&mut self) -> Option<Self::Item> { + self.0.next() + } +} + +impl std::fmt::Debug for RefIter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("RefIter(..)") + } +}
crates/gix-ref-store/src/error.rs @@ -1,0 +1,58 @@ +//! The error type every `gix-ref-store` operation returns. + +use std::path::PathBuf; + +/// Everything that can go wrong reading or writing through a [`crate::RefStore`]. +/// +/// Every variant is a backend I/O or protocol failure; a *rejected* +/// compare-and-swap is not an error at all, since a stale precondition is +/// an expected outcome, not a fault. See [`crate::TxOutcome::Rejected`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Opening the on-disk repository the store reads and writes through + /// failed. The caller should check that `path` names a git repository + /// (or its `.git` directory) and that the process has permission to + /// read it. + #[error("failed to open the repository at {path}: {source}")] + Open { + /// The path that was passed to [`crate::LooseRefStore::open`]. + path: PathBuf, + /// The underlying gitoxide error. + #[source] + source: Box<gix::open::Error>, + }, + + /// A refname string failed gitoxide's own validation (for example, it + /// contained a `..` component or a disallowed character). The caller + /// should reject the name before offering it to a [`crate::RefStore`]. + #[error("invalid reference name: {0}")] + InvalidName(#[from] gix::validate::reference::name::Error), + + /// A read (lookup, peel, or iteration) against the backend failed for + /// a reason other than the ref simply not existing. This wraps + /// whatever gitoxide's own read path reported; the caller should treat + /// it as an I/O-class failure, not a CAS rejection. + #[error("ref-store read failed: {0}")] + Read(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), + + /// A [`crate::RefStore::transaction`] call failed outright — a lock + /// could not be acquired, the on-disk state could not be parsed, or + /// similar — as distinct from a clean CAS rejection, which is + /// reported as `Ok(TxOutcome::Rejected { .. })` rather than this + /// variant. + #[error("ref transaction failed: {0}")] + Transaction(#[from] gix::reference::edit::Error), + + /// The store's own serialization lock (see `loose` module docs for why + /// it exists) could not be acquired within its timeout — most likely + /// another `transaction()` call is legitimately in flight and slow, or + /// a prior process crashed while holding it and left the lock file + /// behind. The caller should retry, and an operator investigating a + /// permanently-stuck store should look for a stale lock file in the + /// repository's git directory. + #[error("could not acquire the ref-store transaction lock: {0}")] + StoreLock(#[source] gix_lock::acquire::Error), +} + +/// The `Result` alias every `gix-ref-store` operation returns. +pub type Result<T> = std::result::Result<T, Error>;
crates/gix-ref-store/src/lib.rs @@ -1,0 +1,83 @@ +//! The pluggable ref store: reads plus atomic multi-ref compare-and-swap, +//! and a loose-ref implementation over gitoxide. +//! +//! This crate is the one place `git-ents` defines a trait gitoxide itself +//! is silent about (`arch.no-object-store-trait` names the ref store as +//! one of the seams that qualifies). It owns two things: the `RefStore` +//! trait, split into a read half ([`RefStoreRead`]) and a write half +//! ([`RefStore`]) per `arch.refstore-read-cas-split`, and +//! [`LooseRefStore`], the local default backend, which writes through +//! gitoxide's own in-process ref transaction rather than shelling out to +//! `git update-ref` (`arch.loose-cas-discipline`). +//! +//! The split exists for the gate (`gate.adoc`): verification is a pure +//! function over ref-store reads and must be statically incapable of +//! performing a write, so it is written against `RefStoreRead` alone. +//! +//! `LooseRefStore` delegates the mechanics of a write (the loose-file +//! format, reflog, packed-refs interaction) to gitoxide, but layers its +//! own serialization lock around every `transaction()` call — see the +//! `loose` module's doc comment for why: the pinned gitoxide version's +//! file-transaction precondition check reads a ref's value *before* +//! acquiring that ref's own lock, which is safe only when every writer +//! already funnels through one in-process handle. Two independent +//! `gix::Repository` handles racing the same ref (two `git-ents` +//! processes, most concretely) can otherwise both observe the same stale +//! precondition and both "win" a `MustNotExist`/`MustExistAndMatch` check. +//! `arch.loose-cas-discipline` asks for this store's *own* CAS discipline +//! for exactly this reason; `LooseRefStore` earns that literally rather +//! than trusting gitoxide's internal ordering to be enough on its own. +//! +//! # Spec coverage +//! +//! This crate implements, from `docs/spec/overview.sdoc`: +//! +//! - `arch.refstore-read-cas-split` — the `RefStoreRead`/`RefStore` split. +//! - `arch.loose-cas-discipline` — [`LooseRefStore`]'s use of gitoxide's +//! own transaction machinery instead of a `git update-ref` subprocess. +//! - `arch.no-object-store-trait` — this crate defines exactly one new +//! trait (the ref store), and touches object access only through +//! gitoxide's own types. +//! +//! # Examples +//! +//! ``` +//! use gix_hash::ObjectId; +//! use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome}; +//! +//! # fn main() -> gix_ref_store::Result<()> { +//! let dir = tempfile::tempdir().expect("tempdir"); +//! gix::init(dir.path()).expect("init"); +//! let store = LooseRefStore::open(dir.path())?; +//! +//! let name: gix::refs::FullName = "refs/meta/config".try_into().expect("valid refname"); +//! let oid = ObjectId::null(gix_hash::Kind::Sha1); +//! +//! // The read half alone is enough to observe the ref not existing yet — +//! // exactly what the gate is handed. +//! let read: &dyn RefStoreRead = &store; +//! assert_eq!(read.get(name.as_ref())?, None); +//! +//! // Only the write half can change it, and only via CAS. +//! let outcome = store.transaction(&[RefEdit { +//! name: name.clone(), +//! expected: Expected::MustNotExist, +//! new: Some(oid), +//! }])?; +//! assert_eq!(outcome, TxOutcome::Applied); +//! assert_eq!(store.get(name.as_ref())?, Some(oid)); +//! # Ok(()) +//! # } +//! ``` + +mod edit; +mod error; +mod loose; +mod read; +mod store; + +pub use edit::{Expected, RefEdit, RefIter, TxOutcome}; +pub use error::{Error, Result}; +pub use loose::LooseRefStore; +pub use read::RefStoreRead; +pub use store::RefStore;
crates/gix-ref-store/src/loose.rs @@ -1,0 +1,416 @@ +//! [`LooseRefStore`]: `RefStore` over gitoxide loose refs and packed-refs — +//! the local default backend (`roots.local`). +//! +//! Atomic multi-ref compare-and-swap is layered on gitoxide's own +//! in-process ref transaction (`Repository::edit_references_as`), which +//! does the actual loose-file write, reflog append, and packed-refs +//! interaction. Nothing in this module shells out to `git`. +//! +//! gitoxide's file-transaction precondition check reads a ref's current +//! value *before* acquiring that ref's lock file, then locks and writes +//! without re-verifying — safe for callers who already serialize through +//! one in-process handle, but not for two independent `gix::Repository` +//! handles (two processes, or two handles opened separately in one +//! process) racing the same ref: both can read the same stale +//! precondition before either has locked anything, and both then "win". +//! `arch.loose-cas-discipline` requires this store to write through *its +//! own* compare-and-swap discipline, so [`LooseRefStore::transaction`] +//! closes that window itself with [`STORE_LOCK_NAME`]: a lock file, +//! separate from any ref's own `.lock`, that every `transaction()` call — +//! from any handle, any process, sharing the same on-disk repository — +//! must hold for the full read-check-write sequence before gitoxide's own +//! per-ref locking ever begins. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit as GixRefEdit, RefLog}; +use gix::refs::{FullName, FullNameRef, Target}; +use gix_hash::ObjectId; + +use crate::{Error, Expected, RefEdit, RefIter, RefStore, RefStoreRead, Result, TxOutcome}; + +/// The lock file name, held for the duration of every +/// [`LooseRefStore::transaction`] call, that closes the precondition +/// TOCTOU window described in this module's doc comment. Deliberately +/// distinct from any ref's own name so it can never collide with a +/// `refs/**` path gitoxide locks internally. +const STORE_LOCK_NAME: &str = "gix-ref-store.lock"; + +/// How long [`LooseRefStore::transaction`] waits to acquire +/// [`STORE_LOCK_NAME`] before giving up. Generous relative to how long a +/// transaction actually holds it (a handful of small file writes), so a +/// legitimate queue of waiters drains rather than spuriously failing. +const STORE_LOCK_TIMEOUT: Duration = Duration::from_secs(5); + +/// The identity every transaction's reflog entry is written under. +/// +/// A `RefStore` write is a plumbing-level operation, not an authored +/// change — `gate.adoc`'s tip invariant is what carries authorship for +/// meta-ref content, via the commit's own signature. The reflog identity +/// here exists only so gitoxide has somewhere to write a committer line; +/// it is deliberately fixed and independent of the local `git config`, so +/// a `LooseRefStore` never depends on `user.name`/`user.email` being set. +const REFLOG_NAME: &str = "gix-ref-store"; +const REFLOG_EMAIL: &str = "ref-store@git-ents.invalid"; +const REFLOG_MESSAGE: &str = "gix-ref-store: transaction"; + +/// [`RefStore`] over a gitoxide repository's loose refs and packed-refs. +/// +/// # Examples +/// +/// ``` +/// use gix_ref_store::LooseRefStore; +/// +/// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> { +/// let store = LooseRefStore::open(dir)?; +/// # let _ = store; +/// # Ok(()) +/// # } +/// ``` +pub struct LooseRefStore { + repo: Mutex<gix::Repository>, + /// The repository's git directory, captured at open time so + /// [`Self::store_lock_path`] can be computed without locking + /// [`Self::repo`] — the store-level lock must be acquired *before* any + /// gitoxide call touches `repo`, not while already holding it. + git_dir: PathBuf, +} + +impl LooseRefStore { + /// Open the ref store for the repository at `path`. + /// + /// `path` may be a work tree or the `.git` directory itself; gitoxide + /// resolves either the same way `git` does. + // @relation(arch.loose-cas-discipline, scope=function) + pub fn open(path: impl AsRef<Path>) -> Result<Self> { + let path = path.as_ref(); + let repo = gix::open(path).map_err(|source| Error::Open { + path: path.to_path_buf(), + source: Box::new(source), + })?; + let git_dir = repo.git_dir().to_path_buf(); + Ok(Self { + repo: Mutex::new(repo), + git_dir, + }) + } + + /// Lock the underlying repository handle, recovering from a poisoned + /// lock rather than panicking: a panic in one caller while holding the + /// lock must not permanently wedge every other caller sharing this + /// store. + fn repo(&self) -> std::sync::MutexGuard<'_, gix::Repository> { + self.repo.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The fixed reflog identity every transaction is written under. See + /// [`REFLOG_NAME`] for why this is not the ambient `git config` + /// identity. + fn committer(&self) -> gix::actor::Signature { + gix::actor::Signature { + name: REFLOG_NAME.into(), + email: REFLOG_EMAIL.into(), + time: gix::date::Time::now_local_or_utc(), + } + } + + /// The path of this store's own serialization lock — see this + /// module's doc comment for why `transaction` needs one beyond + /// whatever gitoxide locks internally. + fn store_lock_path(&self) -> PathBuf { + self.git_dir.join(STORE_LOCK_NAME) + } +} + +impl RefStoreRead for LooseRefStore { + fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> { + let repo = self.repo(); + let Some(mut reference) = repo + .try_find_reference(name.as_bstr()) + .map_err(|error| Error::Read(Box::new(error)))? + else { + return Ok(None); + }; + let id = reference + .follow_to_object() + .map_err(|error| Error::Read(Box::new(error)))?; + Ok(Some(id.detach())) + } + + fn iter_prefix(&self, prefix: &str) -> Result<RefIter> { + let repo = self.repo(); + let platform = repo + .references() + .map_err(|error| Error::Read(Box::new(error)))?; + let iter = platform + .prefixed(prefix) + .map_err(|error| Error::Read(Box::new(error)))?; + + let mut out = Vec::new(); + for reference in iter { + let mut reference = reference.map_err(Error::Read)?; + let name = reference.name().to_owned(); + let oid = reference + .follow_to_object() + .map_err(|error| Error::Read(Box::new(error)))? + .detach(); + out.push(Ok((name, oid))); + } + Ok(RefIter::new(out.into_iter())) + } +} + +impl RefStore for LooseRefStore { + // @relation(arch.loose-cas-discipline, scope=function) + fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> { + // Close the precondition-read-before-lock race described in this + // module's doc comment: no other `transaction()` call, on this + // handle or any other handle sharing this on-disk repository, may + // be inside its own read-check-write sequence while we are. + let _store_lock = gix_lock::Marker::acquire_to_hold_resource( + self.store_lock_path(), + gix_lock::acquire::Fail::AfterDurationWithBackoff(STORE_LOCK_TIMEOUT), + Some(self.git_dir.clone()), + ) + .map_err(Error::StoreLock)?; + + let gix_edits: Vec<GixRefEdit> = edits.iter().map(to_gix_edit).collect(); + let committer = self.committer(); + let mut buf = gix::date::parse::TimeBuf::default(); + match self + .repo() + .edit_references_as(gix_edits, Some(committer.to_ref(&mut buf))) + { + Ok(_applied) => Ok(TxOutcome::Applied), + Err(error) => match rejected_name(&error) { + Some(name) => Ok(TxOutcome::Rejected { name }), + None => Err(Error::Transaction(error)), + }, + } + } +} + +/// Convert one backend-agnostic [`RefEdit`] into gitoxide's own +/// transaction edit type. +fn to_gix_edit(edit: &RefEdit) -> GixRefEdit { + let change = match edit.new { + Some(oid) => Change::Update { + log: LogChange { + mode: RefLog::AndReference, + // gitoxide only auto-creates a missing reflog for + // refs/heads/, refs/remotes/, refs/notes/, and HEAD unless + // told otherwise; this project's refs mostly live under + // refs/meta/*, which needs a log regardless of namespace. + force_create_reflog: true, + message: REFLOG_MESSAGE.into(), + }, + expected: to_previous_value(&edit.expected), + new: Target::Object(oid), + }, + None => Change::Delete { + expected: to_previous_value(&edit.expected), + log: RefLog::AndReference, + }, + }; + GixRefEdit { + change, + name: edit.name.clone(), + deref: false, + } +} + +/// Map a backend-agnostic [`Expected`] precondition onto gitoxide's own +/// [`PreviousValue`]. +fn to_previous_value(expected: &Expected) -> PreviousValue { + match expected { + Expected::Any => PreviousValue::Any, + Expected::MustNotExist => PreviousValue::MustNotExist, + Expected::MustExistAndMatch(oid) => PreviousValue::MustExistAndMatch(Target::Object(*oid)), + } +} + +/// The ref name a rejected transaction's compare-and-swap precondition +/// failed on, or `None` when `error` is not a CAS mismatch (some other +/// failure — a lock timeout, an I/O error — that should propagate as +/// `Err`, not `Ok(TxOutcome::Rejected)`). +fn rejected_name(error: &gix::reference::edit::Error) -> Option<FullName> { + let gix::reference::edit::Error::FileTransactionPrepare(prepare_error) = error else { + return None; + }; + use gix::refs::file::transaction::prepare::Error as PrepareError; + let full_name = match prepare_error { + PrepareError::MustNotExist { full_name, .. } + | PrepareError::MustExist { full_name, .. } + | PrepareError::ReferenceOutOfDate { full_name, .. } + | PrepareError::DeleteReferenceMustExist { full_name, .. } => full_name, + _ => return None, + }; + full_name_from_bytes(full_name.clone()) +} + +/// Reconstruct a validated [`FullName`] from the raw bytes a +/// `prepare::Error` variant carries. +/// +/// These bytes always originated from a [`FullName`] we constructed +/// ourselves in [`to_gix_edit`] and handed to gitoxide, so re-validating +/// them can only fail if gitoxide's own transaction machinery corrupted a +/// name it was given — a backend bug, not a caller error. `None` is +/// returned rather than panicking so a hypothetical future gitoxide +/// version that reports a differently-shaped name degrades to "not +/// recognized as a CAS rejection" instead of crashing the caller. +fn full_name_from_bytes(bytes: gix::bstr::BString) -> Option<FullName> { + FullName::try_from(bytes).ok() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, reason = "unit test")] + + use gix_hash::ObjectId; + + use super::LooseRefStore; + use crate::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome}; + + fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + gix::init(dir.path()).unwrap(); + dir + } + + fn name(s: &str) -> gix::refs::FullName { + s.try_into().unwrap() + } + + fn fixture_oid(byte: u8) -> ObjectId { + ObjectId::from_bytes_or_panic(&[byte; 20]) + } + + #[test] + fn get_returns_none_for_an_absent_ref() { + let dir = init_repo(); + let store = LooseRefStore::open(dir.path()).unwrap(); + assert_eq!(store.get(name("refs/heads/nope").as_ref()).unwrap(), None); + } + + #[test] + fn transaction_creates_a_ref_then_rejects_a_stale_cas() { + let dir = init_repo(); + let store = LooseRefStore::open(dir.path()).unwrap(); + let first = fixture_oid(1); + let second = fixture_oid(2); + + let create = RefEdit { + name: name("refs/heads/topic"), + expected: Expected::MustNotExist, + new: Some(first), + }; + assert_eq!(store.transaction(&[create]).unwrap(), TxOutcome::Applied); + assert_eq!( + store.get(name("refs/heads/topic").as_ref()).unwrap(), + Some(first) + ); + + // Re-asserting must-not-exist while the ref already exists is a + // CAS mismatch, reported as `Rejected`, not an `Err`. + let recreate = RefEdit { + name: name("refs/heads/topic"), + expected: Expected::MustNotExist, + new: Some(second), + }; + let outcome = store.transaction(&[recreate]).unwrap(); + assert_eq!( + outcome, + TxOutcome::Rejected { + name: name("refs/heads/topic") + } + ); + // The rejected edit must not have applied. + assert_eq!( + store.get(name("refs/heads/topic").as_ref()).unwrap(), + Some(first) + ); + } + + #[test] + fn transaction_is_all_or_nothing_across_multiple_edits() { + let dir = init_repo(); + let store = LooseRefStore::open(dir.path()).unwrap(); + let oid = fixture_oid(3); + + // The second edit's precondition already fails (the ref doesn't + // exist yet), so neither edit should apply. + let edits = [ + RefEdit { + name: name("refs/heads/a"), + expected: Expected::MustNotExist, + new: Some(oid), + }, + RefEdit { + name: name("refs/heads/b"), + expected: Expected::MustExistAndMatch(oid), + new: Some(oid), + }, + ]; + let outcome = store.transaction(&edits).unwrap(); + assert!(matches!(outcome, TxOutcome::Rejected { .. })); + assert_eq!(store.get(name("refs/heads/a").as_ref()).unwrap(), None); + } + + #[test] + fn iter_prefix_lists_matching_refs() { + let dir = init_repo(); + let store = LooseRefStore::open(dir.path()).unwrap(); + let oid = fixture_oid(4); + store + .transaction(&[RefEdit { + name: name("refs/meta/thing"), + expected: Expected::MustNotExist, + new: Some(oid), + }]) + .unwrap(); + store + .transaction(&[RefEdit { + name: name("refs/heads/unrelated"), + expected: Expected::MustNotExist, + new: Some(oid), + }]) + .unwrap(); + + let names: Vec<String> = store + .iter_prefix("refs/meta/") + .unwrap() + .map(|item| item.unwrap().0.as_bstr().to_string()) + .collect(); + assert_eq!(names, vec!["refs/meta/thing".to_owned()]); + } + + #[test] + fn delete_removes_a_ref() { + let dir = init_repo(); + let store = LooseRefStore::open(dir.path()).unwrap(); + let oid = fixture_oid(5); + store + .transaction(&[RefEdit { + name: name("refs/meta/gone"), + expected: Expected::MustNotExist, + new: Some(oid), + }]) + .unwrap(); + assert_eq!( + store.get(name("refs/meta/gone").as_ref()).unwrap(), + Some(oid) + ); + + let outcome = store + .transaction(&[RefEdit { + name: name("refs/meta/gone"), + expected: Expected::MustExistAndMatch(oid), + new: None, + }]) + .unwrap(); + assert_eq!(outcome, TxOutcome::Applied); + assert_eq!(store.get(name("refs/meta/gone").as_ref()).unwrap(), None); + } +}
crates/gix-ref-store/src/read.rs @@ -1,0 +1,67 @@ +//! The read half of the `RefStore` seam. +//! +//! `arch.refstore-read-cas-split` requires that a consumer able to check +//! ref state never automatically gains the ability to change it. The gate +//! (`gate.adoc`) is the reason this split exists: it is a pure function +//! over ref-store reads and must be statically incapable of writing. + +use gix::refs::FullNameRef; +use gix_hash::ObjectId; + +use crate::{RefIter, Result}; + +/// The read half of a `RefStore`: everything needed to evaluate the gate +/// (`gate.adoc`) or render a UI, with no path to mutation. +/// +/// A type that also supports writes implements [`crate::RefStore`], which +/// extends this trait with [`crate::RefStore::transaction`]. Code that only +/// ever needs to read — the gate above all — should be written against +/// `RefStoreRead` (or `&dyn RefStoreRead`) so it is impossible, not just +/// disciplined, for it to write. +/// +/// # Examples +/// +/// ``` +/// use gix_ref_store::{LooseRefStore, RefStoreRead}; +/// +/// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> { +/// let store = LooseRefStore::open(dir)?; +/// let read: &dyn RefStoreRead = &store; +/// let name: gix::refs::FullName = "refs/heads/does-not-exist".try_into().expect("valid refname"); +/// assert_eq!(read.get(name.as_ref())?, None); +/// # Ok(()) +/// # } +/// ``` +// @relation(arch.refstore-read-cas-split, scope=file) +pub trait RefStoreRead: Send + Sync { + /// The object id `name` currently points at, or `None` if `name` does + /// not exist. + fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>>; + + /// Every ref under `prefix` (for example `refs/meta/`), with its + /// current tip. + fn iter_prefix(&self, prefix: &str) -> Result<RefIter>; +} + +/// Blanket impl so a `RefStoreRead` behind any indirection remains usable +/// as `RefStoreRead` itself — `&T`, `Box<T>`, and `std::sync::Arc<T>` all +/// forward transparently. +impl<T: RefStoreRead + ?Sized> RefStoreRead for &T { + fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> { + (**self).get(name) + } + + fn iter_prefix(&self, prefix: &str) -> Result<RefIter> { + (**self).iter_prefix(prefix) + } +} + +impl<T: RefStoreRead + ?Sized> RefStoreRead for std::sync::Arc<T> { + fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> { + (**self).get(name) + } + + fn iter_prefix(&self, prefix: &str) -> Result<RefIter> { + (**self).iter_prefix(prefix) + } +}
crates/gix-ref-store/src/store.rs @@ -1,0 +1,50 @@ +//! The write (CAS) half of the `RefStore` seam. + +use crate::{RefEdit, RefStoreRead, Result, TxOutcome}; + +/// The unit of correctness for repository state: a store of named refs, +/// each pointing at an object id, updated only through atomic +/// compare-and-swap transactions. +/// +/// `RefStore` extends [`RefStoreRead`] rather than duplicating its +/// methods, so any code already written against the read half keeps +/// working unchanged when handed a full store. `arch.refstore-read-cas-split` +/// is about restricting what the *gate* is handed, not about the store +/// implementation's own shape: one type legitimately implements both +/// halves, as [`crate::LooseRefStore`] does. +/// +/// # Contract +/// +/// Multi-ref compare-and-swap is contractual, not a capability query. A +/// backend that cannot apply an arbitrary batch of [`RefEdit`]s atomically +/// — every precondition checked against one consistent view, and either +/// every edit applies or none do — does not satisfy this trait, full stop. +/// +/// # Examples +/// +/// ``` +/// use gix_hash::ObjectId; +/// use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome}; +/// +/// # fn run(dir: &std::path::Path, oid: ObjectId) -> gix_ref_store::Result<()> { +/// let store = LooseRefStore::open(dir)?; +/// let name: gix::refs::FullName = "refs/meta/config".try_into().expect("valid refname"); +/// let outcome = store.transaction(&[RefEdit { +/// name: name.clone(), +/// expected: Expected::MustNotExist, +/// new: Some(oid), +/// }])?; +/// assert_eq!(outcome, TxOutcome::Applied); +/// assert_eq!(store.get(name.as_ref())?, Some(oid)); +/// # Ok(()) +/// # } +/// ``` +// @relation(arch.refstore-read-cas-split, scope=file) +pub trait RefStore: RefStoreRead { + /// Apply `edits` as one atomic compare-and-swap transaction: every + /// edit's [`crate::Expected`] precondition is checked against the same + /// consistent view of the store, and either every edit applies or none + /// do. See the trait's contract above — this is not optional behavior + /// a backend may approximate. + fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome>; +}
crates/gix-ref-store/tests/conformance.rs @@ -1,0 +1,295 @@ +//! CAS conformance suite for [`gix_ref_store::LooseRefStore`]. +//! +//! This is the Phase 1 -> 2 gate from `docs/development-plan.adoc`: +//! "`gix-ref-store` passes a CAS conformance suite (concurrent writers, +//! crash injection)." Both properties below exercise gitoxide's own +//! on-disk lock file, not an in-process mutex standing in for it: each +//! "writer" opens its own [`LooseRefStore`] (its own `gix::Repository` +//! handle) against the same on-disk path, the way independent OS +//! processes would. +//! +//! Strategy: rstest table-driven for the fixed-shape crash-injection +//! scenario (a handful of named cases, not an unbounded input space); +//! a hand-rolled multi-thread race for concurrent writers, since the +//! property under test — exactly one of N racing CAS transactions wins, +//! observed from independent store handles — is about thread +//! interleaving, which proptest's shrinking model has nothing to offer +//! for. `@relation(..., role=Verifies)` is on each test. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + reason = "assertion helpers for a conformance suite, not application code" +)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use gix_hash::ObjectId; +use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome}; + +/// A fresh bare repository. Bare so `dir.path()` *is* the git directory — +/// no `.git` subdirectory indirection to get wrong when a test computes a +/// ref's on-disk path directly, as the crash-injection cases below do. +fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + gix::init_bare(dir.path()).expect("gix init_bare"); + dir +} + +fn refname(s: &str) -> gix::refs::FullName { + s.try_into().expect("valid refname") +} + +fn oid(byte: u8) -> ObjectId { + ObjectId::from_bytes_or_panic(&[byte; 20]) +} + +/// N independent store handles race a `MustNotExist` CAS create on the +/// *same* ref, each proposing a different oid. Exactly one must win; every +/// other transaction must observe the ref as already existing and report +/// `Rejected`, never silently overwrite the winner, and never both "win". +// @relation(arch.refstore-read-cas-split, arch.loose-cas-discipline, scope=function, role=Verifies) +#[test] +fn concurrent_writers_exactly_one_cas_wins() { + let dir = init_repo(); + let name = refname("refs/meta/race"); + let writers = 8u8; + + let applied = Arc::new(AtomicUsize::new(0)); + let handles: Vec<_> = (0..writers) + .map(|i| { + let path = dir.path().to_path_buf(); + let name = name.clone(); + let applied = Arc::clone(&applied); + std::thread::spawn(move || { + // Each thread opens its own store handle against the same + // on-disk repository, standing in for independent + // processes contending the same loose ref file. + let store = LooseRefStore::open(&path).expect("open"); + let outcome = store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustNotExist, + new: Some(oid(i)), + }]) + .expect("transaction must not error under contention, only reject"); + if outcome == TxOutcome::Applied { + applied.fetch_add(1, Ordering::SeqCst); + } + outcome + }) + }) + .collect(); + + let outcomes: Vec<TxOutcome> = handles + .into_iter() + .map(|h| h.join().expect("thread")) + .collect(); + + let applied_count = outcomes + .iter() + .filter(|o| **o == TxOutcome::Applied) + .count(); + assert_eq!( + applied_count, 1, + "exactly one of {writers} racing CAS creates must apply; got {applied_count}: {outcomes:?}" + ); + let rejected_count = outcomes + .iter() + .filter(|o| matches!(o, TxOutcome::Rejected { .. })) + .count(); + assert_eq!( + rejected_count, + (writers - 1) as usize, + "every non-winning transaction must be a clean Rejected, not an error or a second Applied" + ); + + // The ref must hold exactly one of the proposed values, not a torn + // write and not a value nobody proposed. + let store = LooseRefStore::open(dir.path()).expect("open"); + let landed = store.get(name.as_ref()).expect("get").expect("ref exists"); + assert!( + (0..writers).map(oid).any(|candidate| candidate == landed), + "the ref must hold exactly one racing writer's proposed oid" + ); +} + +/// Concurrent writers targeting *different* refs must not falsely +/// serialize into contention with one another: independent refs are +/// independent compare-and-swap units. +// @relation(arch.refstore-read-cas-split, scope=function, role=Verifies) +#[test] +fn concurrent_writers_on_distinct_refs_all_apply() { + let dir = init_repo(); + let writers = 8u8; + + let handles: Vec<_> = (0..writers) + .map(|i| { + let path = dir.path().to_path_buf(); + std::thread::spawn(move || { + let store = LooseRefStore::open(&path).expect("open"); + store + .transaction(&[RefEdit { + name: refname(&format!("refs/meta/independent-{i}")), + expected: Expected::MustNotExist, + new: Some(oid(i)), + }]) + .expect("transaction") + }) + }) + .collect(); + + for (i, handle) in handles.into_iter().enumerate() { + let outcome = handle.join().expect("thread"); + assert_eq!( + outcome, + TxOutcome::Applied, + "writer {i} on its own ref must not be blocked by unrelated concurrent writers" + ); + } + + let store = LooseRefStore::open(dir.path()).expect("open"); + for i in 0..writers { + assert_eq!( + store + .get(refname(&format!("refs/meta/independent-{i}")).as_ref()) + .expect("get"), + Some(oid(i)) + ); + } +} + +/// Simulates the on-disk artifact a writer crashing mid-transaction +/// leaves behind: a `.lock` file next to the ref, created but never +/// cleaned up because the process died holding it. A `LooseRefStore` must +/// neither corrupt the ref's last known-good value nor silently apply a +/// transaction while that lock stands; it must fail the contending +/// transaction cleanly, and a fresh transaction must succeed once the +/// stale lock is cleared, as a real recovery path (fsck / restart) would +/// clear it. +// @relation(arch.loose-cas-discipline, scope=function, role=Verifies) +#[rstest::rstest] +#[case::branch_ref("refs/heads/crash-test")] +#[case::meta_ref("refs/meta/crash-test")] +fn crash_injection_stale_lock_fails_safe_and_recovers(#[case] ref_name: &str) { + let dir = init_repo(); + let name = refname(ref_name); + let good = oid(0xAA); + let attempted = oid(0xBB); + + let store = LooseRefStore::open(dir.path()).expect("open"); + let outcome = store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustNotExist, + new: Some(good), + }]) + .expect("baseline transaction"); + assert_eq!(outcome, TxOutcome::Applied); + + // Inject the artifact a crash mid-write leaves: an orphaned lock file + // next to the loose ref, never cleaned up because nothing removed it. + let lock_path = dir.path().join(format!("{ref_name}.lock")); + std::fs::create_dir_all(lock_path.parent().expect("lock has a parent")).expect("mkdir -p"); + std::fs::write(&lock_path, b"orphaned by a simulated crash\n").expect("write stale lock"); + + // A contending transaction must fail safely — not hang forever, not + // silently overwrite the ref — while the stale lock stands. + let result = store.transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustExistAndMatch(good), + new: Some(attempted), + }]); + assert!( + result.is_err(), + "a transaction contending a stale lock must fail, not silently succeed or hang: {result:?}" + ); + + // The ref must be exactly as it was — no torn or partial write from + // the failed attempt. + assert_eq!( + store + .get(name.as_ref()) + .expect("get after failed transaction"), + Some(good), + "a failed transaction under a stale lock must not have changed the ref's value" + ); + + // Recovery: once the stale lock is cleared (as a restart or an fsck + // pass would clear it), a fresh transaction must succeed normally. + std::fs::remove_file(&lock_path).expect("clear the stale lock"); + let recovered = store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustExistAndMatch(good), + new: Some(attempted), + }]) + .expect("transaction after lock clears"); + assert_eq!(recovered, TxOutcome::Applied); + assert_eq!( + store.get(name.as_ref()).expect("get after recovery"), + Some(attempted) + ); +} + +/// The same exactly-one-wins property as +/// [`concurrent_writers_exactly_one_cas_wins`], but racing a +/// `MustExistAndMatch` update against an already-existing ref rather than +/// a `MustNotExist` create — the pattern `gate.fast-forward` and +/// `gate.atomic-cas` actually describe: a meta-ref advances from a known +/// old tip, not from nothing. +// @relation(gate.atomic-cas, arch.loose-cas-discipline, scope=function, role=Verifies) +#[test] +fn concurrent_writers_exactly_one_cas_update_wins() { + let dir = init_repo(); + let name = refname("refs/meta/update-race"); + let store = LooseRefStore::open(dir.path()).expect("open"); + let base = oid(0x10); + store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustNotExist, + new: Some(base), + }]) + .expect("baseline"); + + let writers = 8u8; + let handles: Vec<_> = (0..writers) + .map(|i| { + let path = dir.path().to_path_buf(); + let name = name.clone(); + std::thread::spawn(move || { + let store = LooseRefStore::open(&path).expect("open"); + store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustExistAndMatch(base), + new: Some(oid(0x20 + i)), + }]) + .expect("transaction must not error under contention, only reject") + }) + }) + .collect(); + let outcomes: Vec<TxOutcome> = handles + .into_iter() + .map(|h| h.join().expect("thread")) + .collect(); + + let applied_count = outcomes + .iter() + .filter(|o| **o == TxOutcome::Applied) + .count(); + assert_eq!( + applied_count, 1, + "exactly one of {writers} racing CAS updates from the same known-good tip must apply; got {applied_count}: {outcomes:?}" + ); + + let landed = store.get(name.as_ref()).expect("get").expect("ref exists"); + assert!( + (0..writers) + .map(|i| oid(0x20 + i)) + .any(|candidate| candidate == landed), + "the ref must hold exactly one racing writer's proposed oid, not the stale base value" + ); +}