git-ents.gitmain
⌘K
foforge
commit 5d10a74
refactor: extract shared meta-ref plumbing into a git-store crate

The typed-document-on-a-ref plumbing was copy-pasted across signers and checks: open the odb, rev-parse/peel the ref, commit-tree, update-ref, walk history. It now lives once in a new git-store crate, built on the high-level gix API (0.84, which unifies with facet-git-tree’s gix-object 0.61 / gix-odb 0.81) so refs no longer go through the git CLI. signers and checks become thin delegations; their public types and on-disk tree schema are unchanged.

feat: add git-store crate with a typed refs/meta store over gix 0.84 refactor: delegate signers load/store to git-store refactor: delegate checks load/store/record/runs to git-store build: bump workspace gix 0.83 → 0.84 and drop its malformed feature build: drop facet-git-tree and gix-odb from git-ents deps Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -380,6 +380,12 @@ "rustversion", ] +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -494,6 +500,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "bytesize" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" + [[package]] name = "cc" version = "1.2.65" @@ -573,6 +585,15 @@ "roff", ] +[[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 = "colorchoice" version = "1.0.5" @@ -609,6 +630,21 @@ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -640,6 +676,20 @@ "memchr", ] +[[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.0" @@ -704,6 +754,12 @@ "windows-sys", ] +[[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" @@ -853,12 +909,28 @@ 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 = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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" @@ -952,8 +1024,7 @@ dependencies = [ "clap", "facet", - "facet-git-tree", - "gix-odb", + "git-store", "thiserror", ] @@ -979,6 +1050,78 @@ "tokio", ] +[[package]] +name = "git-store" +version = "0.0.0" +dependencies = [ + "facet", + "facet-git-tree", + "gix", + "thiserror", +] + +[[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 1.15.2", + "thiserror", +] + [[package]] name = "gix-actor" version = "0.41.1" @@ -990,6 +1133,65 @@ "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 1.15.2", + "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 1.15.2", + "thiserror", +] + [[package]] name = "gix-chunk" version = "0.7.2" @@ -999,6 +1201,82 @@ "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 1.15.2", + "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.4" @@ -1011,6 +1289,65 @@ "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" @@ -1026,17 +1363,43 @@ 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 1.15.2", + "thiserror", +] + [[package]] name = "gix-fs" version = "0.21.2" @@ -1051,6 +1414,18 @@ "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" @@ -1074,6 +1449,94 @@ "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 1.15.2", + "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" @@ -1120,15 +1583,30 @@ 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 1.15.2", "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]] @@ -1143,6 +1621,53 @@ "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" @@ -1154,18 +1679,213 @@ "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-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 1.15.2", + "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 1.15.2", + "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 1.15.2", + "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", ] @@ -1179,6 +1899,60 @@ "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" @@ -1194,6 +1968,12 @@ "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" @@ -1201,6 +1981,8 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -1275,6 +2057,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + [[package]] name = "hyper" version = "1.10.1" @@ -1465,6 +2253,16 @@ "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 = "is_terminal_polyfill" version = "1.70.2" @@ -1530,6 +2328,15 @@ "wasm-bindgen", ] +[[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" @@ -1593,6 +2400,17 @@ "syn", ] +[[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.2" @@ -1631,6 +2449,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-traits" version = "0.2.19" @@ -1796,6 +2620,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ + "bytesize", + "human_format", "parking_lot", ] @@ -2047,12 +2873,28 @@ "sha1", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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" @@ -2097,6 +2939,12 @@ 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 = "streaming-iterator" version = "0.1.9" @@ -2324,6 +3172,21 @@ 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" @@ -2441,6 +3304,22 @@ "unicode-ident", ] +[[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" @@ -2450,6 +3329,12 @@ "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-core" version = "0.62.2"
Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/git-ents", "crates/git-ents-server"] +members = ["crates/git-ents", "crates/git-ents-server", "crates/git-store"] [workspace.package] edition = "2024" @@ -37,7 +37,8 @@ clap_mangen = "0.2.31" facet = { version = "0.50.0-rc.0", features = ["reflect"] } facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } -gix = { version = "0.83", features = ["no-default-features"] } +git-store = { path = "crates/git-store" } +gix = "0.84" gix-hash = { version = "0.25", features = ["sha1"] } gix-object = "0.61" gix-odb = "0.81"
crates/git-ents/Cargo.toml @@ -8,8 +8,7 @@ [dependencies] clap = { workspace = true } facet = { workspace = true } -facet-git-tree = { workspace = true } -gix-odb = { workspace = true } +git-store = { workspace = true } thiserror = { workspace = true } [lints]
crates/git-ents/src/checks.rs @@ -2,43 +2,40 @@ //! //! A check is anything a server runs against a push — CI, CD, linting, //! versioning gates, and so on. Their definitions live in exactly one place: -//! the `refs/meta/checks` ref. Its tree is a [`Checks`] document whose `checks/` -//! subtree maps each check name to the command that runs it. The document is -//! read and written with [`facet_git_tree`], so the check set is a typed value -//! that lives in git — versioned, auditable, and itself pushable. Keeping it on -//! a meta ref rather than in the worktree means an untrusted branch cannot -//! rewrite the checks that gate it. +//! the `refs/meta/checks` ref. Its tree is a [`Checks`] document mapping each +//! check name to the command that runs it. The document is read and written +//! through [`git_store`], so the check set is a typed value that lives in git — +//! versioned, auditable, and itself pushable. Keeping it on a meta ref rather +//! than in the worktree means an untrusted branch cannot rewrite the checks +//! that gate it. use std::collections::BTreeMap; use std::path::Path; -use std::process::Command; use facet::Facet; -use facet_git_tree::ObjectId; /// The ref whose tree holds the configured check set. pub const CHECKS_REF: &str = "refs/meta/checks"; -/// The check document stored at [`CHECKS_REF`]: `checks/<name>` maps to that -/// check's definition (its command). +/// The check document stored at [`CHECKS_REF`]: its `checks/` subtree maps each +/// check name to that check's definition (its command). #[derive(Debug, Clone, PartialEq, Eq, Facet)] struct Checks { checks: BTreeMap<String, CheckDef>, } -/// One check's stored definition under `checks/<name>` in [`CHECKS_REF`]. A -/// struct (rather than a bare command blob) so each check can grow per-check -/// settings without a tree-format migration. +/// One check's stored definition. A struct (rather than a bare command blob) so +/// each check can grow per-check settings without a tree-format migration. #[derive(Debug, Clone, PartialEq, Eq, Facet)] struct CheckDef { /// The shell command run for the check. command: String, } -/// One configured check recorded under `checks/` in [`CHECKS_REF`]. +/// One configured check recorded in [`CHECKS_REF`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Check { - /// The `checks/<name>` the definition is stored under — the check's name. + /// The name it is stored under. pub name: String, /// The shell command run for the check (e.g. `cargo fmt --check`). pub command: String, @@ -47,18 +44,9 @@ /// A failure reading or writing the check set. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The repository's object database could not be opened. - #[error("could not open the repository object database")] - Odb, - /// The check set could not be (de)serialized from its git tree. - #[error("could not (de)serialize the check set: {0}")] - Facet(#[from] facet_git_tree::Error), - /// A git invocation needed to read or update the ref failed. - #[error("git {operation} failed")] - Git { - /// The git operation that failed. - operation: &'static str, - }, + /// The check set could not be read from or written to its ref. + #[error(transparent)] + Store(#[from] git_store::Error), } /// Load the configured checks recorded at [`CHECKS_REF`] in `repo`. @@ -67,12 +55,11 @@ /// been pushed yet. A present but unreadable ref is an error so callers can /// distinguish corruption from "no checks configured". pub fn load(repo: &Path) -> Result<Vec<Check>, Error> { - let Some(tree) = checks_tree(repo) else { + let store = git_store::Store::open(repo)?; + let Some(document) = store.load::<Checks>(CHECKS_REF)? else { return Ok(Vec::new()); }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let checks: Checks = facet_git_tree::deserialize(&tree, &odb)?; - Ok(checks + Ok(document .checks .into_iter() .map(|(name, def)| Check { @@ -98,123 +85,8 @@ }) .collect(), }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let tree = facet_git_tree::serialize_into(&document, &odb)?; - let commit = commit_tree(repo, &tree)?; - update_ref(repo, &commit) -} - -/// Resolve [`CHECKS_REF`] to the object id of its tree, or `None` when the ref -/// is absent. -fn checks_tree(repo: &Path) -> Option<ObjectId> { - let spec = format!("{CHECKS_REF}^{{tree}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - ObjectId::from_hex(hex.trim().as_bytes()).ok() -} - -/// Open the repository's durable object database as a `gix` `Find`/`Write` -/// backend. -/// -/// Resolves the *common* git directory rather than `--git-path objects` so that -/// inside a hook the durable store is read, never a receive-pack quarantine. -fn open_odb(repo: &Path) -> Option<gix_odb::Handle> { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--git-common-dir"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let git_dir = String::from_utf8(output.stdout).ok()?; - gix_odb::at(repo.join(git_dir.trim()).join("objects")).ok() -} - -/// Wrap `tree` in a commit, returning its object id. The commit parents on the -/// current [`CHECKS_REF`] when present so updates fast-forward and accrue -/// history; a fixed identity keeps the write self-contained, independent of any -/// ambient git config. -fn commit_tree(repo: &Path, tree: &ObjectId) -> Result<String, Error> { - let mut args = vec!["commit-tree".to_owned(), tree.to_string()]; - if let Some(parent) = checks_commit(repo) { - args.push("-p".to_owned()); - args.push(parent); - } - args.push("-m".to_owned()); - args.push("Update checks".to_owned()); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(&args) - .env("GIT_AUTHOR_NAME", "git-ents") - .env("GIT_AUTHOR_EMAIL", "git-ents@localhost") - .env("GIT_COMMITTER_NAME", "git-ents") - .env("GIT_COMMITTER_EMAIL", "git-ents@localhost") - .output() - .map_err(|_source| Error::Git { - operation: "commit-tree", - })?; - if !output.status.success() { - return Err(Error::Git { - operation: "commit-tree", - }); - } - String::from_utf8(output.stdout) - .map(|stdout| stdout.trim().to_owned()) - .map_err(|_invalid| Error::Git { - operation: "commit-tree", - }) -} - -/// Resolve [`CHECKS_REF`] to the object id of its commit, or `None` when the ref -/// is absent. -fn checks_commit(repo: &Path) -> Option<String> { - let spec = format!("{CHECKS_REF}^{{commit}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - let hex = hex.trim(); - if hex.is_empty() { - None - } else { - Some(hex.to_owned()) - } -} - -/// Point [`CHECKS_REF`] at `commit`. -fn update_ref(repo: &Path, commit: &str) -> Result<(), Error> { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(["update-ref", CHECKS_REF, commit]) - .status() - .map_err(|_source| Error::Git { - operation: "update-ref", - })?; - if status.success() { - Ok(()) - } else { - Err(Error::Git { - operation: "update-ref", - }) - } + git_store::Store::open(repo)?.store(CHECKS_REF, &document, "Update checks")?; + Ok(()) } /// The namespace under which a commit's check runs are recorded: one ref, @@ -263,21 +135,14 @@ /// Record a run of `outcomes` for `commit` as a new commit on /// `refs/meta/runs/<commit>`, parented on the prior run so the ref's commit -/// chain is the run history. The outcomes are written as a [`RunDoc`] tree -/// through [`facet_git_tree`]; the commit's date is the run time. +/// chain is the run history. The commit's date is the run time. pub fn record(repo: &Path, commit: &str, outcomes: &[RunOutcome]) -> Result<(), Error> { - let doc = RunDoc { - results: outcomes - .iter() - .map(|outcome| (outcome.name.clone(), outcome.outcome.clone())) - .collect(), - }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let tree = facet_git_tree::serialize_into(&doc, &odb)?; - let refname = format!("{RUNS_NS}/{commit}"); - let parent = ref_commit(repo, &refname); - let new_commit = commit_run(repo, &tree, parent.as_deref())?; - update_named_ref(repo, &refname, &new_commit) + git_store::Store::open(repo)?.store( + &format!("{RUNS_NS}/{commit}"), + &run_doc(outcomes), + "Record check run", + )?; + Ok(()) } /// Advance the latest run recorded for `commit` to `outcomes`, in place. Unlike @@ -288,72 +153,37 @@ /// When no run has been recorded yet the update starts one, so a worker that /// advances a run is self-healing even if the `queued` record never landed. pub fn update_run(repo: &Path, commit: &str, outcomes: &[RunOutcome]) -> Result<(), Error> { - let doc = RunDoc { - results: outcomes - .iter() - .map(|outcome| (outcome.name.clone(), outcome.outcome.clone())) - .collect(), - }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let tree = facet_git_tree::serialize_into(&doc, &odb)?; - let refname = format!("{RUNS_NS}/{commit}"); - let parent = ref_parent(repo, &refname); - let new_commit = commit_run(repo, &tree, parent.as_deref())?; - update_named_ref(repo, &refname, &new_commit) -} - -/// The first parent of `refname`'s tip commit, or `None` when the tip is a root -/// commit or the ref is absent. -fn ref_parent(repo: &Path, refname: &str) -> Option<String> { - let spec = format!("{refname}^"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - let hex = hex.trim(); - if hex.is_empty() { - None - } else { - Some(hex.to_owned()) - } + git_store::Store::open(repo)?.amend( + &format!("{RUNS_NS}/{commit}"), + &run_doc(outcomes), + "Record check run", + )?; + Ok(()) } /// List the recorded runs per commit, newest commit first. Each commit's runs /// are the ref's commit chain, newest first, with the run time taken from each /// commit's date. pub fn runs(repo: &Path) -> Result<Vec<CommitRuns>, Error> { - let refs = run_refs(repo)?; - if refs.is_empty() { - return Ok(Vec::new()); - } - let odb = open_odb(repo).ok_or(Error::Odb)?; + let store = git_store::Store::open(repo)?; let prefix = format!("{RUNS_NS}/"); let mut commits = Vec::new(); - for refname in refs { + for refname in store.list(&prefix)? { let Some(commit) = refname.strip_prefix(&prefix) else { continue; }; - let mut runs = Vec::new(); - for (run_commit, at) in ref_history(repo, &refname)? { - let Some(tree) = ref_tree(repo, &run_commit) else { - continue; - }; - let doc: RunDoc = facet_git_tree::deserialize(&tree, &odb)?; - runs.push(Run { + let runs = store + .history::<RunDoc>(&refname)? + .into_iter() + .map(|(at, doc)| Run { at, results: doc .results .into_iter() .map(|(name, outcome)| RunOutcome { name, outcome }) .collect(), - }); - } + }) + .collect(); commits.push(CommitRuns { commit: commit.to_owned(), runs, @@ -362,142 +192,13 @@ Ok(commits) } -/// The commits on `refname` as `(object id, committer date)` pairs, newest -/// first — one entry per recorded run. -fn ref_history(repo: &Path, refname: &str) -> Result<Vec<(String, u64)>, Error> { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["log", "--format=%H %ct", refname]) - .output() - .map_err(|_source| Error::Git { operation: "log" })?; - if !output.status.success() { - return Err(Error::Git { operation: "log" }); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - let (hash, ct) = line.split_once(' ')?; - Some((hash.to_owned(), ct.parse().ok()?)) - }) - .collect()) -} - -/// List the `refs/meta/runs/*` refs, newest committed first. -fn run_refs(repo: &Path) -> Result<Vec<String>, Error> { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args([ - "for-each-ref", - "--sort=-committerdate", - "--format=%(refname)", - RUNS_NS, - ]) - .output() - .map_err(|_source| Error::Git { - operation: "for-each-ref", - })?; - if !output.status.success() { - return Err(Error::Git { - operation: "for-each-ref", - }); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::to_owned) - .collect()) -} - -/// Resolve `refname` to the object id of its tree, or `None` when it is absent. -fn ref_tree(repo: &Path, refname: &str) -> Option<ObjectId> { - let spec = format!("{refname}^{{tree}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - ObjectId::from_hex(hex.trim().as_bytes()).ok() -} - -/// Resolve `refname` to the object id of its commit, or `None` when it is -/// absent. -fn ref_commit(repo: &Path, refname: &str) -> Option<String> { - let spec = format!("{refname}^{{commit}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - let hex = hex.trim(); - if hex.is_empty() { - None - } else { - Some(hex.to_owned()) - } -} - -/// Wrap a run `tree` in a commit, parenting on the run's previous ref when -/// present so re-runs accrue history. A fixed identity keeps the write -/// self-contained, independent of any ambient git config. -fn commit_run(repo: &Path, tree: &ObjectId, parent: Option<&str>) -> Result<String, Error> { - let mut args = vec!["commit-tree".to_owned(), tree.to_string()]; - if let Some(parent) = parent { - args.push("-p".to_owned()); - args.push(parent.to_owned()); - } - args.push("-m".to_owned()); - args.push("Record check run".to_owned()); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(&args) - .env("GIT_AUTHOR_NAME", "git-ents") - .env("GIT_AUTHOR_EMAIL", "git-ents@localhost") - .env("GIT_COMMITTER_NAME", "git-ents") - .env("GIT_COMMITTER_EMAIL", "git-ents@localhost") - .output() - .map_err(|_source| Error::Git { - operation: "commit-tree", - })?; - if !output.status.success() { - return Err(Error::Git { - operation: "commit-tree", - }); - } - String::from_utf8(output.stdout) - .map(|stdout| stdout.trim().to_owned()) - .map_err(|_invalid| Error::Git { - operation: "commit-tree", - }) -} - -/// Point `refname` at `commit`. -fn update_named_ref(repo: &Path, refname: &str, commit: &str) -> Result<(), Error> { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(["update-ref", refname, commit]) - .status() - .map_err(|_source| Error::Git { - operation: "update-ref", - })?; - if status.success() { - Ok(()) - } else { - Err(Error::Git { - operation: "update-ref", - }) +/// Build a [`RunDoc`] from a run's `outcomes`. +fn run_doc(outcomes: &[RunOutcome]) -> RunDoc { + RunDoc { + results: outcomes + .iter() + .map(|outcome| (outcome.name.clone(), outcome.outcome.clone())) + .collect(), } } @@ -505,14 +206,13 @@ mod tests { #![allow( clippy::unwrap_used, - clippy::panic, - clippy::arithmetic_side_effects, clippy::indexing_slicing, clippy::let_underscore_must_use, reason = "unit test" )] use std::path::PathBuf; + use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use super::*;
crates/git-ents/src/signers.rs @@ -1,32 +1,30 @@ //! The authorized signer set, sourced from the `refs/meta/auth` ref. //! //! Push authentication trusts exactly one place: the `refs/meta/auth` ref. Its -//! tree is an [`Auth`] document whose `signers/` subtree maps each fingerprint -//! to its OpenSSH public key. The document is read and written with -//! [`facet_git_tree`], so the trust list is a typed value that lives in git — -//! versioned, auditable, and itself pushable. +//! tree is an [`Auth`] document mapping each fingerprint to its OpenSSH public +//! key. The document is read and written through [`git_store`], so the trust +//! list is a typed value that lives in git — versioned, auditable, and itself +//! pushable. use std::collections::BTreeMap; use std::path::Path; -use std::process::Command; use facet::Facet; -use facet_git_tree::ObjectId; /// The ref whose tree holds the authorized signer set. pub const AUTH_REF: &str = "refs/meta/auth"; -/// The authorization document stored at [`AUTH_REF`]: `signers/<fingerprint>` -/// maps to the OpenSSH public key held there. +/// The authorization document stored at [`AUTH_REF`]: its `signers/` subtree +/// maps each fingerprint to the OpenSSH public key held there. #[derive(Debug, Clone, PartialEq, Eq, Facet)] struct Auth { signers: BTreeMap<String, String>, } -/// One authorized signer recorded under `signers/` in [`AUTH_REF`]. +/// One authorized signer recorded in [`AUTH_REF`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Signer { - /// The `signers/<name>` the key is stored under — its fingerprint. + /// The key it is stored under — its fingerprint. pub fingerprint: String, /// The OpenSSH public key the blob holds (`<type> <base64> [comment]`). pub key: String, @@ -35,18 +33,9 @@ /// A failure reading or writing the signer set. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The repository's object database could not be opened. - #[error("could not open the repository object database")] - Odb, - /// The signer set could not be (de)serialized from its git tree. - #[error("could not (de)serialize the signer set: {0}")] - Facet(#[from] facet_git_tree::Error), - /// A git invocation needed to read or update the ref failed. - #[error("git {operation} failed")] - Git { - /// The git operation that failed. - operation: &'static str, - }, + /// The signer set could not be read from or written to its ref. + #[error(transparent)] + Store(#[from] git_store::Error), } /// Load the authorized signers recorded at [`AUTH_REF`] in `repo`. @@ -55,11 +44,10 @@ /// not been pushed yet. A present but unreadable ref is an error so callers can /// fail closed rather than mistake corruption for "no signers". pub fn load(repo: &Path) -> Result<Vec<Signer>, Error> { - let Some(tree) = auth_tree(repo) else { + let store = git_store::Store::open(repo)?; + let Some(auth) = store.load::<Auth>(AUTH_REF)? else { return Ok(Vec::new()); }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let auth: Auth = facet_git_tree::deserialize(&tree, &odb)?; Ok(auth .signers .into_iter() @@ -78,10 +66,8 @@ .map(|signer| (signer.fingerprint.clone(), signer.key.clone())) .collect(), }; - let odb = open_odb(repo).ok_or(Error::Odb)?; - let tree = facet_git_tree::serialize_into(&auth, &odb)?; - let commit = commit_tree(repo, &tree)?; - update_ref(repo, &commit) + git_store::Store::open(repo)?.store(AUTH_REF, &auth, "Update authorized signers")?; + Ok(()) } /// Render `signers` as an OpenSSH `allowed_signers` file that authorizes any @@ -99,132 +85,16 @@ .collect() } -/// Resolve [`AUTH_REF`] to the object id of its tree, or `None` when the ref is -/// absent. -fn auth_tree(repo: &Path) -> Option<ObjectId> { - let spec = format!("{AUTH_REF}^{{tree}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - ObjectId::from_hex(hex.trim().as_bytes()).ok() -} - -/// Open the repository's durable object database as a `gix` `Find`/`Write` -/// backend. -/// -/// Resolves the *common* git directory rather than `--git-path objects`: inside -/// a `pre-receive` hook git points the latter at a quarantine holding only the -/// incoming pack, while the current signer set lives in the durable store — and -/// authorization is against the pre-push set, never the keys being pushed. -fn open_odb(repo: &Path) -> Option<gix_odb::Handle> { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--git-common-dir"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let git_dir = String::from_utf8(output.stdout).ok()?; - gix_odb::at(repo.join(git_dir.trim()).join("objects")).ok() -} - -/// Wrap `tree` in a commit, returning its object id. The commit parents on the -/// current [`AUTH_REF`] when present so updates fast-forward and accrue history; -/// a fixed identity keeps the write self-contained, independent of any ambient -/// git config. -fn commit_tree(repo: &Path, tree: &ObjectId) -> Result<String, Error> { - let mut args = vec!["commit-tree".to_owned(), tree.to_string()]; - if let Some(parent) = auth_commit(repo) { - args.push("-p".to_owned()); - args.push(parent); - } - args.push("-m".to_owned()); - args.push("Update authorized signers".to_owned()); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(&args) - .env("GIT_AUTHOR_NAME", "git-ents") - .env("GIT_AUTHOR_EMAIL", "git-ents@localhost") - .env("GIT_COMMITTER_NAME", "git-ents") - .env("GIT_COMMITTER_EMAIL", "git-ents@localhost") - .output() - .map_err(|_source| Error::Git { - operation: "commit-tree", - })?; - if !output.status.success() { - return Err(Error::Git { - operation: "commit-tree", - }); - } - String::from_utf8(output.stdout) - .map(|stdout| stdout.trim().to_owned()) - .map_err(|_invalid| Error::Git { - operation: "commit-tree", - }) -} - -/// Resolve [`AUTH_REF`] to the object id of its commit, or `None` when the ref -/// is absent. -fn auth_commit(repo: &Path) -> Option<String> { - let spec = format!("{AUTH_REF}^{{commit}}"); - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", "--quiet", &spec]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let hex = String::from_utf8(output.stdout).ok()?; - let hex = hex.trim(); - if hex.is_empty() { - None - } else { - Some(hex.to_owned()) - } -} - -/// Point [`AUTH_REF`] at `commit`. -fn update_ref(repo: &Path, commit: &str) -> Result<(), Error> { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(["update-ref", AUTH_REF, commit]) - .status() - .map_err(|_source| Error::Git { - operation: "update-ref", - })?; - if status.success() { - Ok(()) - } else { - Err(Error::Git { - operation: "update-ref", - }) - } -} - #[cfg(test)] mod tests { #![allow( clippy::unwrap_used, - clippy::panic, - clippy::arithmetic_side_effects, clippy::let_underscore_must_use, reason = "unit test" )] use std::path::PathBuf; + use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use super::*;
crates/git-store/Cargo.toml @@ -1,0 +1,15 @@ +[package] +name = "git-store" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true
crates/git-store/src/lib.rs @@ -1,0 +1,219 @@ +//! Typed documents on `refs/meta/*` refs, stored as git object graphs. +//! +//! A [`Store`] reads and writes [`Facet`] values to a ref's tree through +//! [`facet_git_tree`]: the value becomes a git tree, the tree is wrapped in a +//! commit parented on the ref's prior tip, and the ref is moved to it. The +//! commit chain is the document's history and each commit's date is its +//! timestamp, so nothing about versioning has to be modeled in the tree +//! itself. This is the single home for the plumbing that the signer set, the +//! check set, and the run log all share. + +use std::cmp::Reverse; +use std::path::Path; + +use facet::Facet; +use gix::ObjectId; +use gix::objs::{Commit, FindExt as _, Write as _}; +use gix::refs::transaction::PreviousValue; + +/// The author and committer identity stamped on every write, fixed so a write +/// is self-contained and independent of any ambient git config. +const IDENTITY_NAME: &str = "git-ents"; +/// The email paired with [`IDENTITY_NAME`]. +const IDENTITY_EMAIL: &str = "git-ents@localhost"; + +/// A failure opening the store or reading or writing one of its refs. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The repository could not be opened. + #[error("could not open the repository")] + Open(#[from] Box<gix::open::Error>), + /// The repository's object database could not be opened. + #[error("could not open the repository object database")] + Odb, + /// A document could not be (de)serialized from its git tree. + #[error("could not (de)serialize the document: {0}")] + Facet(#[from] facet_git_tree::Error), + /// A ref could not be read, listed, or updated. + #[error("git ref operation failed: {0}")] + Ref(String), + /// A git object could not be read or written. + #[error("git object operation failed: {0}")] + Object(String), +} + +/// A repository's typed `refs/meta/*` store. +/// +/// Refs are read and updated through the high-level [`gix`] API, while all +/// object IO uses an object database opened on the *common* git directory +/// rather than `--git-path objects`: inside a hook git points the latter at a +/// receive-pack quarantine holding only the incoming pack, while the documents +/// we read and write live in the durable store. +pub struct Store { + repo: gix::Repository, + odb: gix::odb::Handle, +} + +impl Store { + /// Open the typed store for the repository at `repo`. + pub fn open(repo: &Path) -> Result<Self, Error> { + let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; + let odb = gix::odb::at(repo.common_dir().join("objects")).map_err(|_io| Error::Odb)?; + Ok(Self { repo, odb }) + } + + /// Load the document on `refname`, or `None` when the ref is absent. + pub fn load<T: for<'a> Facet<'a>>(&self, refname: &str) -> Result<Option<T>, Error> { + let Some(commit) = self.ref_commit(refname)? else { + return Ok(None); + }; + let tree = self.read_commit(&commit)?.tree; + Ok(Some(facet_git_tree::deserialize(&tree, &self.odb)?)) + } + + /// Write `value` to `refname` as a new commit on top of the ref's current + /// tip, so the update fast-forwards and accrues history. + pub fn store<T: for<'a> Facet<'a>>( + &self, + refname: &str, + value: &T, + message: &str, + ) -> Result<(), Error> { + let tree = facet_git_tree::serialize_into(value, &self.odb)?; + let parents = self.ref_commit(refname)?.into_iter().collect(); + let commit = self.write_commit(tree, parents, message)?; + self.set_ref(refname, commit) + } + + /// Write `value` to `refname` in place, replacing the ref's tip commit + /// (re-parented on the tip's own parents) rather than appending. Lets a + /// single document advance through intermediate states without a commit per + /// transition. When the ref is absent this starts a fresh history. + pub fn amend<T: for<'a> Facet<'a>>( + &self, + refname: &str, + value: &T, + message: &str, + ) -> Result<(), Error> { + let tree = facet_git_tree::serialize_into(value, &self.odb)?; + let parents = match self.ref_commit(refname)? { + Some(tip) => self.read_commit(&tip)?.parents, + None => Vec::new(), + }; + let commit = self.write_commit(tree, parents, message)?; + self.set_ref(refname, commit) + } + + /// The documents on `refname`'s commit chain as `(committer date, value)` + /// pairs, newest first — one entry per commit, following first parents. + pub fn history<T: for<'a> Facet<'a>>(&self, refname: &str) -> Result<Vec<(u64, T)>, Error> { + let mut out = Vec::new(); + let mut cursor = self.ref_commit(refname)?; + while let Some(oid) = cursor { + let commit = self.read_commit(&oid)?; + let value = facet_git_tree::deserialize(&commit.tree, &self.odb)?; + out.push((commit.seconds, value)); + cursor = commit.parents.into_iter().next(); + } + Ok(out) + } + + /// The full names of the refs under `prefix`, newest committer date first. + pub fn list(&self, prefix: &str) -> Result<Vec<String>, Error> { + let platform = self + .repo + .references() + .map_err(|error| Error::Ref(error.to_string()))?; + let iter = platform + .prefixed(prefix) + .map_err(|error| Error::Ref(error.to_string()))?; + let mut refs = Vec::new(); + for reference in iter { + let mut reference = reference.map_err(|error| Error::Ref(error.to_string()))?; + let name = reference.name().as_bstr().to_string(); + let oid = reference + .peel_to_id() + .map_err(|error| Error::Ref(error.to_string()))? + .detach(); + refs.push((self.read_commit(&oid)?.seconds, name)); + } + refs.sort_by_key(|(seconds, _name)| Reverse(*seconds)); + Ok(refs.into_iter().map(|(_seconds, name)| name).collect()) + } + + /// Resolve `refname` to the object id of its commit, or `None` when absent. + fn ref_commit(&self, refname: &str) -> Result<Option<ObjectId>, Error> { + match self + .repo + .try_find_reference(refname) + .map_err(|error| Error::Ref(error.to_string()))? + { + Some(mut reference) => { + let id = reference + .peel_to_id() + .map_err(|error| Error::Ref(error.to_string()))?; + Ok(Some(id.detach())) + } + None => Ok(None), + } + } + + /// Read `oid`'s tree, parents, and committer date from the durable store. + fn read_commit(&self, oid: &ObjectId) -> Result<CommitFacts, Error> { + let mut buffer = Vec::new(); + let commit = self + .odb + .find_commit(oid, &mut buffer) + .map_err(|error| Error::Object(error.to_string()))?; + let seconds = commit + .committer() + .map_err(|error| Error::Object(error.to_string()))? + .seconds(); + Ok(CommitFacts { + tree: commit.tree(), + parents: commit.parents().collect(), + seconds: u64::try_from(seconds).unwrap_or(0), + }) + } + + /// Wrap `tree` in a commit over `parents` and write it to the durable store. + fn write_commit( + &self, + tree: ObjectId, + parents: Vec<ObjectId>, + message: &str, + ) -> Result<ObjectId, Error> { + let signature = gix::actor::Signature { + name: IDENTITY_NAME.into(), + email: IDENTITY_EMAIL.into(), + time: gix::date::Time::now_utc(), + }; + let commit = Commit { + tree, + parents: parents.into(), + author: signature.clone(), + committer: signature, + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + self.odb + .write(&commit) + .map_err(|error| Error::Object(error.to_string())) + } + + /// Point `refname` at `commit`, creating or force-updating it. + fn set_ref(&self, refname: &str, commit: ObjectId) -> Result<(), Error> { + self.repo + .reference(refname, commit, PreviousValue::Any, "git-ents: update") + .map_err(|error| Error::Ref(error.to_string()))?; + Ok(()) + } +} + +/// The facts read off a commit: its tree, its parents, and its committer date. +struct CommitFacts { + tree: ObjectId, + parents: Vec<ObjectId>, + seconds: u64, +}