refactor!: reset codebase for the abstractions-first redo
commit
8f07ae8refactor!: reset codebase for the abstractions-first redo
Delete all 25 crates, server hooks, and the scale-out design doc; the abstractions and FAQ docs are the surviving source of truth. History is the salvage path, archived at the annotated tag pre-redo.
removes: all crates, hooks/, docs/scale-out.adoc Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
Cargo.lock
@@ -1,5935 +1,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "acdc-converters-core"
-version = "0.1.0"
-source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
-dependencies = [
- "acdc-parser",
- "bitflags 2.13.0",
- "thiserror 2.0.18",
- "tracing",
-]
-
-[[package]]
-name = "acdc-converters-html"
-version = "0.1.0"
-source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
-dependencies = [
- "acdc-converters-core",
- "acdc-converters-terminal",
- "acdc-parser",
- "base64",
- "chrono",
- "sha2",
- "thiserror 2.0.18",
- "tracing",
-]
-
-[[package]]
-name = "acdc-converters-terminal"
-version = "0.1.0"
-source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
-dependencies = [
- "acdc-converters-core",
- "acdc-parser",
- "asciicast-rs",
- "comfy-table",
- "crossterm",
- "libghostty-vt",
- "serde",
- "serde_json",
- "syntect",
- "thiserror 2.0.18",
- "tracing",
- "unicode-width",
-]
-
-[[package]]
-name = "acdc-parser"
-version = "0.9.0"
-source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
-dependencies = [
- "bitflags 2.13.0",
- "bumpalo",
- "csv",
- "encoding_rs",
- "evalexpr",
- "peg",
- "rustc-hash",
- "self_cell",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
- "tracing",
- "url",
-]
-
-[[package]]
-name = "adler2"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
-
-[[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 = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "ansi-str"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
-dependencies = [
- "ansitok",
-]
-
-[[package]]
-name = "ansitok"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
-dependencies = [
- "nom",
- "vte",
-]
-
-[[package]]
-name = "anyhow"
-version = "1.0.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
-
-[[package]]
-name = "arborium"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "abb7a99c146e42da48d5783335814d9bd0f088143aa51fb5ea5b59855df5ca70"
-dependencies = [
- "arborium-asciidoc",
- "arborium-bash",
- "arborium-c",
- "arborium-cpp",
- "arborium-css",
- "arborium-go",
- "arborium-highlight",
- "arborium-html",
- "arborium-javascript",
- "arborium-json",
- "arborium-markdown",
- "arborium-python",
- "arborium-rust",
- "arborium-theme",
- "arborium-toml",
- "arborium-tree-sitter",
- "arborium-typescript",
- "arborium-yaml",
- "dlmalloc",
-]
-
-[[package]]
-name = "arborium-asciidoc"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58270e81ade15dc1f1397c2acb827bdaf9de4a3adb94a776a0b7d0d5ce57cfc7"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-bash"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aa5ef1fa9c298d071d0817b068880a22c86f11202508215bdfddc3386aae643"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-c"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d44b533ac7c14da8a26dfb8f7b4bfa29da969a7f01015d63a3ed4097af14c9d"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-cpp"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f307d299c2089784e11c6a93374b80c0d5f42c46bdc9f10bcd02163a1fd30bb"
-dependencies = [
- "arborium-c",
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-css"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "040085bd355e439915eaeca9ed9db316a8dc3795f36c23948f216a808192376f"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-go"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab9a45c7deab55b1dfed13074fe9cbc0bfa8d4a40e4bc509f4295291e53efdec"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-highlight"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65635c663883cb887d52abce06650f81e4abeb7bfd8e79a555f6a0d8e8f0a31b"
-dependencies = [
- "arborium-theme",
- "arborium-tree-sitter",
- "streaming-iterator",
-]
-
-[[package]]
-name = "arborium-html"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b109985d29a842c1fe328b567ac48f8d2211bce778dfe12da6297dd58adac2a"
-dependencies = [
- "arborium-css",
- "arborium-javascript",
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-javascript"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "424c648bf3075e137a5620318a7f865cf320c5f43163d3094764d4cc050e7cc0"
-dependencies = [
- "arborium-jsdoc",
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-jsdoc"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f06a77e65f79ded95575bbc9b48a3f213e82ba1a23e5bc64ee5df77f14166cf4"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-json"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcbd847461cd81d50cd34454870359bc73ac1b96512eb8a33943fe54cba82d9f"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-markdown"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5bd7c263aeb25a87a0b9eb0d90d2f559eb4e7ec4bce2a305271893902201a6e1"
-dependencies = [
- "arborium-html",
- "arborium-sysroot",
- "arborium-toml",
- "arborium-yaml",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-python"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ade74ad0c312807b2f4c439c7e416c6de41f5a6a638cdea1d5e93470d87ba91"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-rust"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a291f443c124244ff7dea2248a1f2bf60a3b4d6ab0585784ea680dc33fd7cd47"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-sysroot"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59d99d80550b726f9dec7ee6d07118c31e08b10e729ac488eabd4c10603dc841"
-dependencies = [
- "cc",
- "dlmalloc",
-]
-
-[[package]]
-name = "arborium-theme"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a00a8f02b994f454d9703dd6a8b6075ba778320550d699198faff1882594c0f7"
-
-[[package]]
-name = "arborium-toml"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "102a80466c8afd64c0c05c57cae30e5ff5ddbf3f943065fdd8fbe64331b49952"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-tree-sitter"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b27f3bd6bb7192e4a19dc177748532b305e9052839b34e55705f53e5eea2d5ca"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "regex",
- "regex-syntax",
- "streaming-iterator",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-typescript"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a226449c2081a153662af88ac5db08d6ee2654ac9022bdc1bacb8a2769053d"
-dependencies = [
- "arborium-javascript",
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arborium-yaml"
-version = "2.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d83a224e870912490905c2cb21fa1d0b055ae9f88a47329f5849662cb9fe656f"
-dependencies = [
- "arborium-sysroot",
- "cc",
- "tree-sitter-language",
-]
-
-[[package]]
-name = "arc-swap"
-version = "1.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "ariadne"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5"
-dependencies = [
- "unicode-width",
- "yansi",
-]
-
-[[package]]
-name = "arrayvec"
-version = "0.7.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
-
-[[package]]
-name = "asciicast-rs"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0ba000776cf478263576bca61217ca5557749c55a8a2d2daf973047188be03"
-dependencies = [
- "rgb",
- "ruzstd",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "askama"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
-dependencies = [
- "askama_macros",
- "itoa",
- "percent-encoding",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "askama_derive"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
-dependencies = [
- "askama_parser",
- "basic-toml",
- "glob",
- "memchr",
- "proc-macro2",
- "quote",
- "rustc-hash",
- "serde",
- "serde_derive",
- "syn",
-]
-
-[[package]]
-name = "askama_macros"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
-dependencies = [
- "askama_derive",
-]
-
-[[package]]
-name = "askama_parser"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
-dependencies = [
- "rustc-hash",
- "serde",
- "serde_derive",
- "unicode-ident",
- "winnow",
-]
-
-[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
-[[package]]
-name = "autocfg"
-version = "1.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
-
-[[package]]
-name = "aws-lc-rs"
-version = "1.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
-dependencies = [
- "aws-lc-sys",
- "zeroize",
-]
-
-[[package]]
-name = "aws-lc-sys"
-version = "0.42.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
-dependencies = [
- "cc",
- "cmake",
- "dunce",
- "fs_extra",
- "pkg-config",
-]
-
-[[package]]
-name = "axum"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
-dependencies = [
- "axum-core",
- "base64",
- "bytes",
- "form_urlencoded",
- "futures-util",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-util",
- "itoa",
- "matchit",
- "memchr",
- "mime",
- "percent-encoding",
- "pin-project-lite",
- "serde_core",
- "serde_json",
- "serde_path_to_error",
- "serde_urlencoded",
- "sha1",
- "sync_wrapper",
- "tokio",
- "tokio-tungstenite",
- "tower",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "axum-core"
-version = "0.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "http-body-util",
- "mime",
- "pin-project-lite",
- "sync_wrapper",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "backend-conformance"
-version = "0.0.0"
-dependencies = [
- "git-backend",
- "git-protocol",
- "git-store",
- "gix-hash",
- "gix-object",
- "gix-reachability",
- "odb-files",
- "refstore-files",
- "tempfile",
-]
-
-[[package]]
-name = "base64"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
-
-[[package]]
-name = "basic-toml"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "bincode"
-version = "1.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
-dependencies = [
- "serde",
-]
-
-[[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 = "block-buffer"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
-dependencies = [
- "hybrid-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 = "bumpalo"
-version = "3.20.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
-
-[[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.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
-
-[[package]]
-name = "bytesize"
-version = "2.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e"
-
-[[package]]
-name = "camino"
-version = "1.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
-
-[[package]]
-name = "cc"
-version = "1.2.65"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
-dependencies = [
- "find-msvc-tools",
- "jobserver",
- "libc",
- "shlex",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "cfg_aliases"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
-
-[[package]]
-name = "cfg_aliases"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
-
-[[package]]
-name = "chacha20"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.3.0",
- "rand_core 0.10.1",
-]
-
-[[package]]
-name = "chrono"
-version = "0.4.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
-dependencies = [
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "serde",
- "wasm-bindgen",
- "windows-link",
-]
-
-[[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 = "cmake"
-version = "0.1.58"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "cmov"
-version = "0.5.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
-
-[[package]]
-name = "combine"
-version = "4.6.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
-dependencies = [
- "bytes",
- "memchr",
-]
-
-[[package]]
-name = "comfy-table"
-version = "7.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
-dependencies = [
- "ansi-str",
- "console",
- "crossterm",
- "unicode-segmentation",
- "unicode-width",
-]
-
-[[package]]
-name = "console"
-version = "0.16.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
-dependencies = [
- "encode_unicode",
- "libc",
- "unicode-width",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "const-fnv1a-hash"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
-
-[[package]]
-name = "const-oid"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
-
-[[package]]
-name = "convert_case"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
-dependencies = [
- "unicode-segmentation",
-]
-
-[[package]]
-name = "coolor"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
-
-[[package]]
-name = "core-foundation"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "cpufeatures"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crc-fast"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
-dependencies = [
- "digest 0.10.7",
- "spin",
-]
-
-[[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.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
-dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-epoch"
-version = "0.9.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
-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 = "crossterm"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
-dependencies = [
- "bitflags 2.13.0",
- "crossterm_winapi",
- "derive_more",
- "document-features",
- "mio",
- "parking_lot",
- "rustix",
- "signal-hook 0.3.18",
- "signal-hook-mio",
- "winapi",
-]
-
-[[package]]
-name = "crossterm_winapi"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-dependencies = [
- "winapi",
-]
-
-[[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 = "crypto-common"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
-dependencies = [
- "hybrid-array",
-]
-
-[[package]]
-name = "csv"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
-dependencies = [
- "csv-core",
- "itoa",
- "ryu",
- "serde_core",
-]
-
-[[package]]
-name = "csv-core"
-version = "0.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "ctutils"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
-dependencies = [
- "cmov",
-]
-
-[[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 = "data-encoding"
-version = "2.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
-
-[[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 2.0.18",
-]
-
-[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-
-[[package]]
-name = "derive_more"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
-dependencies = [
- "derive_more-impl",
-]
-
-[[package]]
-name = "derive_more-impl"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
-dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
- "rustc_version",
- "syn",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer 0.10.4",
- "crypto-common 0.1.7",
-]
-
-[[package]]
-name = "digest"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
-dependencies = [
- "block-buffer 0.12.1",
- "const-oid",
- "crypto-common 0.2.2",
- "ctutils",
-]
-
-[[package]]
-name = "displaydoc"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "dlmalloc"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675"
-dependencies = [
- "cfg-if",
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
-[[package]]
-name = "downcast-rs"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
-
-[[package]]
-name = "dunce"
-version = "1.0.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
-
-[[package]]
-name = "dyn-clone"
-version = "1.0.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
-
-[[package]]
-name = "effect-dispatcher"
-version = "0.0.0"
-dependencies = [
- "git-backend",
- "gix-hash",
- "refstore-postgres",
- "uuid",
-]
-
-[[package]]
-name = "either"
-version = "1.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
-
-[[package]]
-name = "encode_unicode"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
-
-[[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 0.61.2",
-]
-
-[[package]]
-name = "evalexpr"
-version = "13.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601"
-
-[[package]]
-name = "exec-sprites"
-version = "0.0.0"
-dependencies = [
- "git-backend",
- "gix-hash",
-]
-
-[[package]]
-name = "facet"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd3e58a40571ddd336865ec9643ea43d5cf93c35ae4f5ad5d903dc4e427a2e03"
-dependencies = [
- "autocfg",
- "facet-core",
- "facet-macros",
- "facet-reflect",
-]
-
-[[package]]
-name = "facet-core"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2c2ad271d54e76358f179bc5f75483c4ed025d1009a8599f299ac7bee99dbdd"
-dependencies = [
- "autocfg",
- "camino",
- "const-fnv1a-hash",
- "iddqd",
- "impls",
- "indexmap",
-]
-
-[[package]]
-name = "facet-dessert"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39aaece2ca1e5aad3e2bee0f4fea23ae0a11eb5a0b88bb265ec09c39d580aac5"
-dependencies = [
- "facet-core",
- "facet-reflect",
-]
-
-[[package]]
-name = "facet-error"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175f8aab2cbd354a829214f7f033c5dff59c60b66848fc833116a4dabcc7f7c6"
-dependencies = [
- "facet",
-]
-
-[[package]]
-name = "facet-format"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64c612924dc3d45853f81e9cfafc513f3ec0be5982f943a7945f49f2492effb1"
-dependencies = [
- "facet-core",
- "facet-dessert",
- "facet-path",
- "facet-reflect",
- "facet-solver",
-]
-
-[[package]]
-name = "facet-git-tree"
-version = "0.1.0"
-source = "git+https://github.com/git-ents/facet-git-tree#ec7f4a18ff822d148ab9c4c5360564d38f8a4813"
-dependencies = [
- "facet",
- "gix-hash",
- "gix-object",
- "gix-odb",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "facet-json"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82902c758e29af6599f852dd783206697fd33383fa499b07bdcddbd8eb1ef4d0"
-dependencies = [
- "facet",
- "facet-core",
- "facet-format",
- "facet-reflect",
- "weavy",
-]
-
-[[package]]
-name = "facet-macro-parse"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77a43cd88c3df76b7606194a059973aea078940de8cc442dca7c327f51bbc014"
-dependencies = [
- "facet-macro-types",
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "facet-macro-types"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dce57ed093067e4a3a1ab42c8c84eec1a25af7726d43a22e390734b29b947ec"
-dependencies = [
- "proc-macro2",
- "quote",
- "unsynn",
-]
-
-[[package]]
-name = "facet-macros"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ae8b8eb7169a41245f31bda2912a3c7d33e0efa7269c0c8847b54377e31ff68"
-dependencies = [
- "facet-macros-impl",
-]
-
-[[package]]
-name = "facet-macros-impl"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d7d66574a47f07e87058ce6e429a7be58fd77c7686e572cb1f85d8a6a840c6"
-dependencies = [
- "facet-macro-parse",
- "facet-macro-types",
- "proc-macro2",
- "quote",
- "strsim",
- "unsynn",
-]
-
-[[package]]
-name = "facet-path"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a21c558bfb27bf957ead130a3b7e5ea2dee7ccbfb31a524053f061efa164d6f"
-dependencies = [
- "facet-core",
-]
-
-[[package]]
-name = "facet-pretty"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dbcf41fe256543a1cdc8a75b3ef130100fbe4a7e180b20472d5601e03349d5b"
-dependencies = [
- "facet-core",
- "facet-reflect",
- "owo-colors",
- "terminal-light",
-]
-
-[[package]]
-name = "facet-reflect"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8486d2a82dcb2a1bdca472c2cdc513d0feacda3af2ffd50fb9e04f8dedda8735"
-dependencies = [
- "facet-core",
- "facet-path",
- "hashbrown 0.17.1",
- "smallvec 2.0.0-alpha.12",
-]
-
-[[package]]
-name = "facet-solver"
-version = "0.50.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5c800eb08ef5e1f18ffafcddf40da5d9ea3c86b8f0486f6ee077b0a3d497812"
-dependencies = [
- "facet-core",
- "facet-reflect",
- "strsim",
-]
-
-[[package]]
-name = "fallible-iterator"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
-
-[[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 = "figue"
-version = "5.0.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d683110c41f61f23e580c2f11887cfd2667a75388dbbc551b0fec025d3f6aaa8"
-dependencies = [
- "ariadne",
- "camino",
- "facet",
- "facet-core",
- "facet-error",
- "facet-format",
- "facet-json",
- "facet-pretty",
- "facet-reflect",
- "figue-attrs",
- "heck",
- "indexmap",
- "owo-colors",
- "strip-ansi-escapes",
- "strsim",
- "supports-color 3.0.2",
- "tracing",
- "unicode-width",
-]
-
-[[package]]
-name = "figue-attrs"
-version = "5.0.0-rc.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfb26d9b503638fe61b04ef7d76d0d53f39993549948c0267e05094185b6b808"
-dependencies = [
- "facet",
-]
-
-[[package]]
-name = "filedescriptor"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
-dependencies = [
- "libc",
- "thiserror 1.0.69",
- "winapi",
-]
-
-[[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 = "flate2"
-version = "1.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
-dependencies = [
- "crc32fast",
- "miniz_oxide",
-]
-
-[[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 = "form_urlencoded"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
-dependencies = [
- "percent-encoding",
-]
-
-[[package]]
-name = "fs_extra"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
-
-[[package]]
-name = "futures-channel"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
-dependencies = [
- "futures-core",
- "futures-sink",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
-
-[[package]]
-name = "futures-io"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
-
-[[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-sink"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
-
-[[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-io",
- "futures-macro",
- "futures-sink",
- "futures-task",
- "memchr",
- "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.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
-dependencies = [
- "cfg-if",
- "js-sys",
- "libc",
- "wasi 0.11.1+wasi-snapshot-preview1",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi 5.3.0",
- "wasip2",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
-dependencies = [
- "cfg-if",
- "js-sys",
- "libc",
- "r-efi 6.0.0",
- "rand_core 0.10.1",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "git-anchor"
-version = "0.0.0"
-dependencies = [
- "facet",
- "git-store",
- "gix",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-backend"
-version = "0.0.0"
-dependencies = [
- "gix-hash",
- "gix-object",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-cache-proxy"
-version = "0.0.0"
-dependencies = [
- "axum",
- "git-backend",
- "git-ents-core",
- "git-member",
- "git-protocol",
- "git-store",
- "gix-hash",
- "gix-object",
- "gix-reachability",
- "odb-files",
- "refstore-files",
- "tempfile",
- "tokio",
-]
-
-[[package]]
-name = "git-comment"
-version = "0.0.0"
-dependencies = [
- "facet",
- "facet-git-tree",
- "git-anchor",
- "git-store",
- "gix",
-]
-
-[[package]]
-name = "git-effect"
-version = "0.0.0"
-dependencies = [
- "facet",
- "git-backend",
- "git-store",
- "git-toolchain",
- "gix-hash",
- "portable-pty",
- "tempfile",
- "tokio",
- "uuid",
-]
-
-[[package]]
-name = "git-ents"
-version = "0.0.0"
-dependencies = [
- "crossterm",
- "facet",
- "facet-pretty",
- "figue",
- "form_urlencoded",
- "futures-util",
- "git-anchor",
- "git-comment",
- "git-effect",
- "git-ents-core",
- "git-ents-server",
- "git-member",
- "git-store",
- "git-toolchain",
- "inquire",
- "signal-hook 0.3.18",
- "tempfile",
- "tokio",
- "tokio-tungstenite",
- "ureq",
-]
-
-[[package]]
-name = "git-ents-core"
-version = "0.0.0"
-dependencies = [
- "facet",
- "git-store",
- "gix",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-ents-server"
-version = "0.0.0"
-dependencies = [
- "acdc-converters-core",
- "acdc-converters-html",
- "acdc-converters-terminal",
- "acdc-parser",
- "arborium",
- "askama",
- "axum",
- "backend-conformance",
- "facet",
- "figue",
- "form_urlencoded",
- "getrandom 0.4.3",
- "git-anchor",
- "git-backend",
- "git-comment",
- "git-effect",
- "git-ents-core",
- "git-hydrate",
- "git-maintenance",
- "git-member",
- "git-protocol",
- "git-signed-push",
- "git-store",
- "git-toolchain",
- "gix-actor",
- "gix-date",
- "gix-hash",
- "gix-object",
- "gix-reachability",
- "maud",
- "odb-files",
- "odb-tigris",
- "portable-pty",
- "pulldown-cmark",
- "refstore-files",
- "refstore-postgres",
- "rstest",
- "tempfile",
- "tokio",
- "uuid",
-]
-
-[[package]]
-name = "git-hydrate"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "git-ents-core",
- "git-member",
- "git-protocol",
- "gix-hash",
- "gix-object",
- "gix-reachability",
- "odb-files",
- "odb-tigris",
- "refstore-files",
- "refstore-postgres",
- "tempfile",
- "uuid",
-]
-
-[[package]]
-name = "git-maintenance"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "effect-dispatcher",
- "git-backend",
- "git-protocol",
- "git-store",
- "gix-hash",
- "gix-object",
- "gix-pack",
- "gix-reachability",
- "odb-files",
- "odb-tigris",
- "refstore-files",
- "refstore-postgres",
- "tempfile",
- "tokio",
- "tokio-postgres",
- "uuid",
-]
-
-[[package]]
-name = "git-member"
-version = "0.0.0"
-dependencies = [
- "facet",
- "git-ents-core",
- "git-store",
- "gix",
- "iddqd",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-protocol"
-version = "0.0.0"
-dependencies = [
- "git-backend",
- "git-ents-core",
- "git-member",
- "git-signed-push",
- "gix",
- "gix-actor",
- "gix-date",
- "gix-features",
- "gix-hash",
- "gix-object",
- "gix-pack",
- "gix-reachability",
- "odb-files",
- "refstore-files",
- "rstest",
- "tempfile",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-signed-push"
-version = "0.0.0"
-dependencies = [
- "git-ents-core",
- "git-member",
- "git-store",
- "tempfile",
-]
-
-[[package]]
-name = "git-store"
-version = "0.0.0"
-dependencies = [
- "facet",
- "facet-git-tree",
- "git-backend",
- "gix",
- "refstore-files",
- "tempfile",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "git-toolchain"
-version = "0.0.0"
-dependencies = [
- "facet",
- "facet-git-tree",
- "git-backend",
- "git-store",
- "gix",
- "gix-pack",
- "odb-baked",
- "odb-files",
- "rayon",
- "semver",
- "spdx",
- "target-lexicon",
- "tempfile",
- "thiserror 2.0.18",
-]
-
-[[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 0.4.4",
- "smallvec 1.15.2",
- "thiserror 2.0.18",
-]
-
-[[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 1.15.2",
- "thiserror 2.0.18",
- "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 2.0.18",
-]
-
-[[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 1.15.2",
- "thiserror 2.0.18",
- "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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
- "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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 1.15.2",
- "thiserror 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 1.15.2",
- "thiserror 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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-diff",
- "gix-error",
- "gix-features",
- "gix-hash",
- "gix-hashtable",
- "gix-object",
- "gix-path",
- "gix-tempfile",
- "gix-traverse",
- "memmap2",
- "parking_lot",
- "smallvec 1.15.2",
- "thiserror 2.0.18",
- "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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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-reachability"
-version = "0.0.0"
-dependencies = [
- "git-backend",
- "gix-actor",
- "gix-date",
- "gix-hash",
- "gix-object",
- "odb-files",
- "odb-tigris",
- "refstore-files",
- "tempfile",
- "thiserror 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 0.61.2",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 0.4.4",
- "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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 2.0.18",
-]
-
-[[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 = "h2"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
-dependencies = [
- "atomic-waker",
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "http",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
-[[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 = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
-name = "hmac"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
-dependencies = [
- "digest 0.11.3",
-]
-
-[[package]]
-name = "http"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
-dependencies = [
- "bytes",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
-dependencies = [
- "bytes",
- "http",
-]
-
-[[package]]
-name = "http-body-util"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
-
-[[package]]
-name = "httpdate"
-version = "1.0.3"
-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 = "humantime"
-version = "2.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
-
-[[package]]
-name = "hybrid-array"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
-dependencies = [
- "typenum",
-]
-
-[[package]]
-name = "hyper"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
-dependencies = [
- "atomic-waker",
- "bytes",
- "futures-channel",
- "futures-core",
- "h2",
- "http",
- "http-body",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "smallvec 1.15.2",
- "tokio",
- "want",
-]
-
-[[package]]
-name = "hyper-rustls"
-version = "0.27.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
-dependencies = [
- "http",
- "hyper",
- "hyper-util",
- "rustls",
- "tokio",
- "tokio-rustls",
- "tower-service",
-]
-
-[[package]]
-name = "hyper-util"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
-dependencies = [
- "base64",
- "bytes",
- "futures-channel",
- "futures-util",
- "http",
- "http-body",
- "hyper",
- "ipnet",
- "libc",
- "percent-encoding",
- "pin-project-lite",
- "socket2",
- "tokio",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "iana-time-zone"
-version = "0.1.65"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "icu_collections"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
-dependencies = [
- "displaydoc",
- "potential_utf",
- "utf8_iter",
- "yoke",
- "zerofrom",
- "zerovec",
-]
-
-[[package]]
-name = "icu_locale_core"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
-dependencies = [
- "displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
-dependencies = [
- "icu_collections",
- "icu_normalizer_data",
- "icu_properties",
- "icu_provider",
- "smallvec 1.15.2",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer_data"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
-
-[[package]]
-name = "icu_properties"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
-dependencies = [
- "icu_collections",
- "icu_locale_core",
- "icu_properties_data",
- "icu_provider",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "icu_properties_data"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
-
-[[package]]
-name = "icu_provider"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
-dependencies = [
- "displaydoc",
- "icu_locale_core",
- "writeable",
- "yoke",
- "zerofrom",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "iddqd"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7cf7e72dd082126bd727c1f8bd2d5229c2780258c0b3c7bc33eaf0f4ea5f0fa"
-dependencies = [
- "allocator-api2",
- "equivalent",
- "foldhash",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "idna"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
-dependencies = [
- "idna_adapter",
- "smallvec 1.15.2",
- "utf8_iter",
-]
-
-[[package]]
-name = "idna_adapter"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
-dependencies = [
- "icu_normalizer",
- "icu_properties",
-]
-
-[[package]]
-name = "impls"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
-
-[[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 = "inquire"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
-dependencies = [
- "bitflags 2.13.0",
- "crossterm",
- "dyn-clone",
- "unicode-segmentation",
- "unicode-width",
-]
-
-[[package]]
-name = "int-enum"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e366a1634cccc76b4cfd3e7580de9b605e4d93f1edac48d786c1f867c0def495"
-dependencies = [
- "proc-macro2",
- "proc-macro2-diagnostics",
- "quote",
- "syn",
-]
-
-[[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 = "ipnet"
-version = "2.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
-
-[[package]]
-name = "is-terminal"
-version = "0.4.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
-dependencies = [
- "hermit-abi",
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "is_ci"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
-
-[[package]]
-name = "itertools"
-version = "0.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
-dependencies = [
- "either",
-]
-
-[[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.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
-dependencies = [
- "defmt",
- "jiff-static",
- "jiff-tzdb-platform",
- "log",
- "portable-atomic",
- "portable-atomic-util",
- "serde_core",
- "windows-link",
-]
-
-[[package]]
-name = "jiff-static"
-version = "0.2.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "jiff-tzdb"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590"
-
-[[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 = "jni"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
-dependencies = [
- "cfg-if",
- "combine",
- "jni-macros",
- "jni-sys",
- "log",
- "simd_cesu8",
- "thiserror 2.0.18",
- "walkdir",
- "windows-link",
-]
-
-[[package]]
-name = "jni-macros"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
-dependencies = [
- "proc-macro2",
- "quote",
- "rustc_version",
- "simd_cesu8",
- "syn",
-]
-
-[[package]]
-name = "jni-sys"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
-dependencies = [
- "jni-sys-macros",
-]
-
-[[package]]
-name = "jni-sys-macros"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
-dependencies = [
- "quote",
- "syn",
-]
-
-[[package]]
-name = "jobserver"
-version = "0.1.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
-dependencies = [
- "getrandom 0.4.3",
- "libc",
-]
-
-[[package]]
-name = "js-sys"
-version = "0.3.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
-dependencies = [
- "cfg-if",
- "futures-util",
- "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 = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-
-[[package]]
-name = "libc"
-version = "0.2.186"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
-
-[[package]]
-name = "libghostty-vt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04c77176dd05b2b5718046d807e786473f463ea6d478d56f8c0105e2fe6f71f5"
-dependencies = [
- "bitflags 2.13.0",
- "int-enum",
- "libghostty-vt-sys",
-]
-
-[[package]]
-name = "libghostty-vt-sys"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "404671567c0ac43389a69ae424eefc89390a25d3a5386a54249cdaf13e01b411"
-
-[[package]]
-name = "libredox"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "linked-hash-map"
-version = "0.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
-
-[[package]]
-name = "litemap"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
-
-[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
-[[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 = "lru-slab"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
-
-[[package]]
-name = "matchit"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
-
-[[package]]
-name = "maud"
-version = "0.27.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
-dependencies = [
- "axum-core",
- "http",
- "itoa",
- "maud_macros",
-]
-
-[[package]]
-name = "maud_macros"
-version = "0.27.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
-dependencies = [
- "proc-macro2",
- "proc-macro2-diagnostics",
- "quote",
- "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 = "md-5"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
-dependencies = [
- "cfg-if",
- "digest 0.11.3",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
-
-[[package]]
-name = "memmap2"
-version = "0.9.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
-dependencies = [
- "adler2",
- "simd-adler32",
-]
-
-[[package]]
-name = "mio"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
-dependencies = [
- "libc",
- "log",
- "wasi 0.11.1+wasi-snapshot-preview1",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "mutants"
-version = "0.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
-
-[[package]]
-name = "nix"
-version = "0.28.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
-dependencies = [
- "bitflags 2.13.0",
- "cfg-if",
- "cfg_aliases 0.1.1",
- "libc",
-]
-
-[[package]]
-name = "nix"
-version = "0.31.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
-dependencies = [
- "bitflags 2.13.0",
- "cfg-if",
- "cfg_aliases 0.2.1",
- "libc",
-]
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "nonempty"
-version = "0.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
-
-[[package]]
-name = "num-conv"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "objc2-core-foundation"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
-dependencies = [
- "bitflags 2.13.0",
-]
-
-[[package]]
-name = "objc2-system-configuration"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
-dependencies = [
- "objc2-core-foundation",
-]
-
-[[package]]
-name = "object_store"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "765784b4390c6bcf80316e5a22f4e3661b639c9d8c83246856643c27d8ce9dbe"
-dependencies = [
- "async-trait",
- "aws-lc-rs",
- "base64",
- "bytes",
- "chrono",
- "crc-fast",
- "form_urlencoded",
- "futures-channel",
- "futures-core",
- "futures-util",
- "http",
- "http-body-util",
- "humantime",
- "hyper",
- "itertools",
- "md-5",
- "parking_lot",
- "percent-encoding",
- "quick-xml 0.40.1",
- "rand 0.10.2",
- "reqwest",
- "rustls-pki-types",
- "serde",
- "serde_json",
- "serde_urlencoded",
- "thiserror 2.0.18",
- "tokio",
- "tracing",
- "url",
- "wasm-bindgen-futures",
- "web-time",
-]
-
-[[package]]
-name = "odb-baked"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "git-store",
- "gix-hash",
- "gix-object",
- "odb-files",
- "tempfile",
-]
-
-[[package]]
-name = "odb-files"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "git-store",
- "gix",
- "gix-hash",
- "gix-object",
- "gix-pack",
- "uuid",
-]
-
-[[package]]
-name = "odb-tiered"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "gix-features",
- "gix-hash",
- "gix-object",
- "gix-pack",
- "odb-tigris",
- "tempfile",
- "uuid",
-]
-
-[[package]]
-name = "odb-tigris"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "gix-features",
- "gix-hash",
- "gix-object",
- "gix-pack",
- "object_store",
- "tempfile",
- "tokio",
- "uuid",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-
-[[package]]
-name = "onig"
-version = "6.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
-dependencies = [
- "bitflags 2.13.0",
- "libc",
- "once_cell",
- "onig_sys",
-]
-
-[[package]]
-name = "onig_sys"
-version = "69.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
-dependencies = [
- "cc",
- "pkg-config",
-]
-
-[[package]]
-name = "openssl-probe"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
-
-[[package]]
-name = "owo-colors"
-version = "4.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
-dependencies = [
- "supports-color 2.1.0",
- "supports-color 3.0.2",
-]
-
-[[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 1.15.2",
- "windows-link",
-]
-
-[[package]]
-name = "peg"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773"
-dependencies = [
- "peg-macros",
- "peg-runtime",
-]
-
-[[package]]
-name = "peg-macros"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee"
-dependencies = [
- "peg-runtime",
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "peg-runtime"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6"
-
-[[package]]
-name = "percent-encoding"
-version = "2.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-
-[[package]]
-name = "phf"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
-dependencies = [
- "phf_shared",
- "serde",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
-dependencies = [
- "siphasher",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-
-[[package]]
-name = "pkg-config"
-version = "0.3.33"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
-
-[[package]]
-name = "plist"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
-dependencies = [
- "base64",
- "indexmap",
- "quick-xml 0.41.0",
- "serde",
- "time",
-]
-
-[[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 = "portable-pty"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
-dependencies = [
- "anyhow",
- "bitflags 1.3.2",
- "downcast-rs",
- "filedescriptor",
- "lazy_static",
- "libc",
- "log",
- "nix 0.28.0",
- "serial2",
- "shared_library",
- "shell-words",
- "winapi",
- "winreg",
-]
-
-[[package]]
-name = "postgres-protocol"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
-dependencies = [
- "base64",
- "byteorder",
- "bytes",
- "fallible-iterator",
- "hmac",
- "md-5",
- "memchr",
- "rand 0.10.2",
- "sha2",
- "stringprep",
-]
-
-[[package]]
-name = "postgres-types"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
-dependencies = [
- "bytes",
- "fallible-iterator",
- "postgres-protocol",
-]
-
-[[package]]
-name = "potential_utf"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
-dependencies = [
- "zerovec",
-]
-
-[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
-[[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 = "proc-macro2-diagnostics"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "version_check",
-]
-
-[[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 = "pulldown-cmark"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
-dependencies = [
- "bitflags 2.13.0",
- "memchr",
- "pulldown-cmark-escape",
- "unicase",
-]
-
-[[package]]
-name = "pulldown-cmark-escape"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
-
-[[package]]
-name = "quick-xml"
-version = "0.40.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
-dependencies = [
- "memchr",
- "serde",
-]
-
-[[package]]
-name = "quick-xml"
-version = "0.41.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "quinn"
-version = "0.11.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
-dependencies = [
- "bytes",
- "cfg_aliases 0.2.1",
- "pin-project-lite",
- "quinn-proto",
- "quinn-udp",
- "rustc-hash",
- "rustls",
- "socket2",
- "thiserror 2.0.18",
- "tokio",
- "tracing",
- "web-time",
-]
-
-[[package]]
-name = "quinn-proto"
-version = "0.11.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
-dependencies = [
- "aws-lc-rs",
- "bytes",
- "getrandom 0.4.3",
- "lru-slab",
- "rand 0.10.2",
- "rand_pcg",
- "ring",
- "rustc-hash",
- "rustls",
- "rustls-pki-types",
- "slab",
- "thiserror 2.0.18",
- "tinyvec",
- "tracing",
- "web-time",
-]
-
-[[package]]
-name = "quinn-udp"
-version = "0.5.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
-dependencies = [
- "cfg_aliases 0.2.1",
- "libc",
- "once_cell",
- "socket2",
- "tracing",
- "windows-sys 0.61.2",
-]
-
-[[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 = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
-
-[[package]]
-name = "r-efi"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-
-[[package]]
-name = "rand"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
-dependencies = [
- "chacha20",
- "getrandom 0.4.3",
- "rand_core 0.10.1",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
-
-[[package]]
-name = "rand_pcg"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
-dependencies = [
- "rand_core 0.10.1",
-]
-
-[[package]]
-name = "rayon"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
-dependencies = [
- "either",
- "rayon-core",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
-[[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 = "refstore-files"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "git-store",
- "gix",
- "gix-hash",
-]
-
-[[package]]
-name = "refstore-postgres"
-version = "0.0.0"
-dependencies = [
- "backend-conformance",
- "git-backend",
- "git-protocol",
- "gix-hash",
- "gix-object",
- "odb-tiered",
- "odb-tigris",
- "tempfile",
- "tokio",
- "tokio-postgres",
- "uuid",
-]
-
-[[package]]
-name = "regex"
-version = "1.12.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
-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 = "reqwest"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
-dependencies = [
- "base64",
- "bytes",
- "futures-core",
- "futures-util",
- "h2",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-rustls",
- "hyper-util",
- "js-sys",
- "log",
- "percent-encoding",
- "pin-project-lite",
- "quinn",
- "rustls",
- "rustls-pki-types",
- "rustls-platform-verifier",
- "sync_wrapper",
- "tokio",
- "tokio-rustls",
- "tokio-util",
- "tower",
- "tower-http",
- "tower-service",
- "url",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-streams",
- "web-sys",
-]
-
-[[package]]
-name = "rgb"
-version = "0.8.53"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
-
-[[package]]
-name = "ring"
-version = "0.17.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
-dependencies = [
- "cc",
- "cfg-if",
- "getrandom 0.2.17",
- "libc",
- "untrusted",
- "windows-sys 0.52.0",
-]
-
-[[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-hash"
-version = "2.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
-
-[[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 0.61.2",
-]
-
-[[package]]
-name = "rustls"
-version = "0.23.41"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
-dependencies = [
- "aws-lc-rs",
- "log",
- "once_cell",
- "ring",
- "rustls-pki-types",
- "rustls-webpki",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "rustls-native-certs"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
-dependencies = [
- "openssl-probe",
- "rustls-pki-types",
- "schannel",
- "security-framework",
-]
-
-[[package]]
-name = "rustls-pki-types"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
-dependencies = [
- "web-time",
- "zeroize",
-]
-
-[[package]]
-name = "rustls-platform-verifier"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
-dependencies = [
- "core-foundation",
- "core-foundation-sys",
- "jni",
- "log",
- "once_cell",
- "rustls",
- "rustls-native-certs",
- "rustls-platform-verifier-android",
- "rustls-webpki",
- "security-framework",
- "security-framework-sys",
- "webpki-root-certs",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "rustls-platform-verifier-android"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
-
-[[package]]
-name = "rustls-webpki"
-version = "0.103.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
-dependencies = [
- "aws-lc-rs",
- "ring",
- "rustls-pki-types",
- "untrusted",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ruzstd"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
-
-[[package]]
-name = "ryu"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
-
-[[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 = "schannel"
-version = "0.1.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "security-framework"
-version = "3.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
-dependencies = [
- "bitflags 2.13.0",
- "core-foundation",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
-[[package]]
-name = "security-framework-sys"
-version = "2.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "self_cell"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
-
-[[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",
- "serde_derive",
-]
-
-[[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 = "serde_json"
-version = "1.0.150"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
-dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
-]
-
-[[package]]
-name = "serde_path_to_error"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
-dependencies = [
- "itoa",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "serial2"
-version = "0.2.37"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
-dependencies = [
- "cfg-if",
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "sha1"
-version = "0.10.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "digest 0.10.7",
-]
-
-[[package]]
-name = "sha1-checked"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
-dependencies = [
- "digest 0.10.7",
- "sha1",
-]
-
-[[package]]
-name = "sha2"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.3.0",
- "digest 0.11.3",
-]
-
-[[package]]
-name = "shared_library"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
-dependencies = [
- "lazy_static",
- "libc",
-]
-
-[[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.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
-dependencies = [
- "libc",
- "signal-hook-registry",
-]
-
-[[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-mio"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
-dependencies = [
- "libc",
- "mio",
- "signal-hook 0.3.18",
-]
-
-[[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 = "simd-adler32"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
-
-[[package]]
-name = "simd_cesu8"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
-dependencies = [
- "rustc_version",
- "simdutf8",
-]
-
-[[package]]
-name = "simdutf8"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
-
-[[package]]
-name = "siphasher"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
-
-[[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 = "smallvec"
-version = "2.0.0-alpha.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857"
-
-[[package]]
-name = "socket2"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "spdx"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8da593e30beb790fc9424502eb898320b44e5eb30367dbda1c1edde8e2f32d7"
-dependencies = [
- "smallvec 1.15.2",
-]
-
-[[package]]
-name = "spin"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
-
-[[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 = "streaming-iterator"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
-
-[[package]]
-name = "stringprep"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
-dependencies = [
- "unicode-bidi",
- "unicode-normalization",
- "unicode-properties",
-]
-
-[[package]]
-name = "strip-ansi-escapes"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
-dependencies = [
- "vte",
-]
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
-[[package]]
-name = "supports-color"
-version = "2.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
-dependencies = [
- "is-terminal",
- "is_ci",
-]
-
-[[package]]
-name = "supports-color"
-version = "3.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
-dependencies = [
- "is_ci",
-]
-
-[[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 = "sync_wrapper"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "synstructure"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "syntect"
-version = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
-dependencies = [
- "bincode",
- "flate2",
- "fnv",
- "once_cell",
- "onig",
- "plist",
- "regex-syntax",
- "serde",
- "serde_derive",
- "serde_json",
- "thiserror 2.0.18",
- "walkdir",
- "yaml-rust",
-]
-
-[[package]]
-name = "target-lexicon"
-version = "0.13.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
-
-[[package]]
-name = "tempfile"
-version = "3.27.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
-dependencies = [
- "fastrand",
- "getrandom 0.4.3",
- "once_cell",
- "rustix",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "terminal-light"
-version = "1.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79307346b35bf24ed47c895c71f24653fab51c37576c7df7b5efdb8d519aee6d"
-dependencies = [
- "coolor",
- "crossterm",
- "thiserror 1.0.69",
- "xterm-query",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
-dependencies = [
- "thiserror-impl 2.0.18",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[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 = "time"
-version = "0.3.53"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
-dependencies = [
- "deranged",
- "num-conv",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
-
-[[package]]
-name = "time-macros"
-version = "0.2.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
-[[package]]
-name = "tinystr"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
-dependencies = [
- "displaydoc",
- "zerovec",
-]
-
-[[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 = "tokio"
-version = "1.52.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tokio-postgres"
-version = "0.7.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
-dependencies = [
- "async-trait",
- "byteorder",
- "bytes",
- "fallible-iterator",
- "futures-channel",
- "futures-util",
- "log",
- "parking_lot",
- "percent-encoding",
- "phf",
- "pin-project-lite",
- "postgres-protocol",
- "postgres-types",
- "rand 0.10.2",
- "socket2",
- "tokio",
- "tokio-util",
- "whoami",
-]
-
-[[package]]
-name = "tokio-rustls"
-version = "0.26.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
-dependencies = [
- "rustls",
- "tokio",
-]
-
-[[package]]
-name = "tokio-tungstenite"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
-dependencies = [
- "futures-util",
- "log",
- "rustls",
- "rustls-pki-types",
- "tokio",
- "tokio-rustls",
- "tungstenite",
- "webpki-roots 0.26.11",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.7.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "pin-project-lite",
- "tokio",
-]
-
-[[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 = "tower"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
-dependencies = [
- "futures-core",
- "futures-util",
- "pin-project-lite",
- "sync_wrapper",
- "tokio",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "tower-http"
-version = "0.6.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
-dependencies = [
- "bitflags 2.13.0",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "pin-project-lite",
- "tower",
- "tower-layer",
- "tower-service",
- "url",
-]
-
-[[package]]
-name = "tower-layer"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
-name = "tracing"
-version = "0.1.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
-dependencies = [
- "log",
- "pin-project-lite",
- "tracing-attributes",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-attributes"
-version = "0.1.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
-dependencies = [
- "once_cell",
-]
-
-[[package]]
-name = "tree-sitter-language"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
-
-[[package]]
-name = "try-lock"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
-
-[[package]]
-name = "tungstenite"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
-dependencies = [
- "bytes",
- "data-encoding",
- "http",
- "httparse",
- "log",
- "rand 0.9.4",
- "rustls",
- "rustls-pki-types",
- "sha1",
- "thiserror 2.0.18",
-]
-
-[[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 = "unicase"
-version = "2.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
-
-[[package]]
-name = "unicode-bidi"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
-
-[[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 = "unicode-properties"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
-
-[[package]]
-name = "unicode-segmentation"
-version = "1.13.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
-
-[[package]]
-name = "unicode-width"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
-
-[[package]]
-name = "unsynn"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
-dependencies = [
- "mutants",
- "proc-macro2",
- "rustc-hash",
-]
-
-[[package]]
-name = "untrusted"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
-
-[[package]]
-name = "ureq"
-version = "3.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
-dependencies = [
- "base64",
- "flate2",
- "log",
- "percent-encoding",
- "rustls",
- "rustls-pki-types",
- "ureq-proto",
- "utf8-zero",
- "webpki-roots 1.0.8",
-]
-
-[[package]]
-name = "ureq-proto"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
-dependencies = [
- "base64",
- "http",
- "httparse",
- "log",
-]
-
-[[package]]
-name = "url"
-version = "2.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
-dependencies = [
- "form_urlencoded",
- "idna",
- "percent-encoding",
- "serde",
-]
-
-[[package]]
-name = "utf8-zero"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
-
-[[package]]
-name = "utf8_iter"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
-
-[[package]]
-name = "uuid"
-version = "1.23.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
-dependencies = [
- "getrandom 0.4.3",
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "version_check"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
-
-[[package]]
-name = "vte"
-version = "0.14.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
-dependencies = [
- "arrayvec",
- "memchr",
-]
-
-[[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 = "want"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
-dependencies = [
- "try-lock",
-]
-
-[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-
-[[package]]
-name = "wasi"
-version = "0.14.7+wasi-0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
-dependencies = [
- "wasip2",
-]
-
-[[package]]
-name = "wasip2"
-version = "1.0.4+wasi-0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
-dependencies = [
- "wit-bindgen",
-]
-
-[[package]]
-name = "wasite"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
-dependencies = [
- "wasi 0.14.7+wasi-0.2.4",
-]
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-futures"
-version = "0.4.76"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "wasm-streams"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
-dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
-[[package]]
-name = "weavy"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30261027ea4225cf7c8287aa70bd103c1e78508606c77caf24bb468326ce7b5f"
-
-[[package]]
-name = "web-sys"
-version = "0.3.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "web-time"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "webpki-root-certs"
-version = "1.0.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
-dependencies = [
- "rustls-pki-types",
-]
-
-[[package]]
-name = "webpki-roots"
-version = "0.26.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
-dependencies = [
- "webpki-roots 1.0.8",
-]
-
-[[package]]
-name = "webpki-roots"
-version = "1.0.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
-dependencies = [
- "rustls-pki-types",
-]
-
-[[package]]
-name = "whoami"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
-dependencies = [
- "libc",
- "libredox",
- "objc2-system-configuration",
- "wasite",
- "web-sys",
-]
-
-[[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 0.61.2",
-]
-
-[[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"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-implement"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "windows-interface"
-version = "0.59.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "windows-link"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-
-[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets",
-]
-
-[[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 = "windows-targets"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
-dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-
-[[package]]
-name = "winnow"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "winreg"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
-dependencies = [
- "winapi",
-]
-
-[[package]]
-name = "wit-bindgen"
-version = "0.57.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
-
-[[package]]
-name = "writeable"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
-
-[[package]]
-name = "xterm-query"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e468ba3129b3d5acd2655410cf12adccd818746e2b8d7fd4b2d22a6e3515ec98"
-dependencies = [
- "nix 0.31.3",
- "thiserror 1.0.69",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "yaml-rust"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
-dependencies = [
- "linked-hash-map",
-]
-
-[[package]]
-name = "yansi"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
-
-[[package]]
-name = "yoke"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
-dependencies = [
- "stable_deref_trait",
- "yoke-derive",
- "zerofrom",
-]
-
-[[package]]
-name = "yoke-derive"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "synstructure",
-]
-
-[[package]]
-name = "zerocopy"
-version = "0.8.52"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
-dependencies = [
- "zerocopy-derive",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.8.52"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "zerofrom"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
-dependencies = [
- "zerofrom-derive",
-]
-
-[[package]]
-name = "zerofrom-derive"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "synstructure",
-]
-
-[[package]]
-name = "zeroize"
-version = "1.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
-
-[[package]]
-name = "zerotrie"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
-dependencies = [
- "displaydoc",
- "yoke",
- "zerofrom",
-]
-
-[[package]]
-name = "zerovec"
-version = "0.11.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
-dependencies = [
- "yoke",
- "zerofrom",
- "zerovec-derive",
-]
-
-[[package]]
-name = "zerovec-derive"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "zlib-rs"
-version = "0.6.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5"
-
-[[package]]
-name = "zmij"
-version = "1.0.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
crates/backend-conformance/Cargo.toml
@@ -1,22 +1,0 @@
-[package]
-name = "backend-conformance"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-git-backend = { workspace = true }
-git-protocol = { workspace = true }
-gix-reachability = { workspace = true }
-git-store = { workspace = true, features = ["test-support"] }
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-tempfile = { workspace = true }
-
-[dev-dependencies]
-odb-files = { workspace = true }
-refstore-files = { workspace = true }
-
-[lints]
-workspace = true
crates/backend-conformance/src/collector.rs
@@ -1,40 +1,0 @@
-//! [`Collector`]: the seam [`crate::causal_collection_safety`] tests
-//! against. [`NoopCollector`] lets a backend with no GC wired up exercise
-//! what a collection pass must never do — touch a staged/quarantined
-//! object — without asserting behavior no collector implements. A backend
-//! with real GC plugs its own `Collector` (reporting a real
-//! [`Collector::staging_grace`] window, if it has one) into the same
-//! property function instead — `git-maintenance` (WS9) does exactly that
-//! for the files and Tigris backends, including the staging-timeout
-//! boundary (`crates/git-maintenance/tests/conformance.rs`).
-
-use std::time::Duration;
-
-/// A hook onto a backend's collection (GC) pass, for
-/// [`crate::causal_collection_safety`] to drive.
-pub trait Collector {
- /// Run one collection pass now.
- fn collect(&self);
-
- /// The backend's staging grace window, if it bounds staging sessions
- /// with a time-based deadline (correctness rule 1 in
- /// `docs/scale-out.adoc`) rather than promotion alone. `None` for
- /// backends with no time-bounded staging, which is what the local file
- /// backends have today.
- fn staging_grace(&self) -> Option<Duration> {
- None
- }
-}
-
-/// A [`Collector`] that never collects anything and has no grace window —
-/// today's stand-in for backends (`refstore-files`/`odb-files`) that have
-/// no GC wired up yet. Running the suite against it still exercises the
-/// real quarantine/promote path; it just never exercises the "a collection
-/// pass actually reaped something" arm, which has no implementation to
-/// test yet.
-#[derive(Debug, Clone, Copy, Default)]
-pub struct NoopCollector;
-
-impl Collector for NoopCollector {
- fn collect(&self) {}
-}
crates/backend-conformance/src/corpus.rs
@@ -1,186 +1,0 @@
-//! Replay harness for [`git_protocol::CorpusEntry`] (`docs/scale-out.adoc`,
-//! WS0's "op replay corpus" — the conformance seed corpus WS2 replays):
-//! [`replay_corpus`] applies a logged corpus, in order, against any
-//! `RefStore`+`ObjectStore` pair; [`reachable_object_set`] is the
-//! "identical reachable object sets" half of the assertion a replay test
-//! makes (the "identical final refs" half is an ordinary
-//! `RefStore::iter_prefix` comparison, needing no helper here).
-
-use std::collections::BTreeSet;
-
-use git_backend::{Expected, ObjectStore, PackStream, RefEdit, RefStore, TxOutcome};
-use git_protocol::CorpusEntry;
-use gix_hash::ObjectId;
-
-/// Replay `entries`, in order, against `refs`/`objects`: stage each
-/// entry's pack, apply its ref edits as one atomic transaction (the same
-/// shape the entry was originally accepted with), then promote once that
-/// transaction applies — never before, so a replayed-but-rejected entry's
-/// pack stays quarantined rather than becoming visible garbage.
-///
-/// A corpus is expected to replay cleanly against a backend starting from
-/// the same state (typically empty) it was recorded from; an entry whose
-/// recorded `old`/`new` no longer apply against `refs`'s current state is
-/// reported as an error rather than silently skipped, since that means the
-/// corpus and the target have already diverged — exactly what this
-/// harness exists to catch.
-///
-/// # Errors
-///
-/// Returns an error if staging, transacting, or promoting any entry
-/// fails, including a rejected compare-and-swap.
-pub fn replay_corpus(
- entries: &[CorpusEntry],
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
-) -> git_backend::Result<()> {
- for entry in entries {
- let quarantine =
- objects.stage_pack(PackStream::new(std::io::Cursor::new(entry.pack.clone())))?;
- let edits: Vec<RefEdit> = entry
- .ref_edits
- .iter()
- .map(|edit| RefEdit {
- name: edit.name.clone(),
- expected: match edit.old {
- Some(oid) => Expected::MustExistAndMatch(oid),
- None => Expected::MustNotExist,
- },
- new: edit.new,
- })
- .collect();
- match refs.transaction(&edits)? {
- TxOutcome::Applied => objects.promote(quarantine)?,
- TxOutcome::Rejected { name } => {
- return Err(git_backend::Error::RefStore(format!(
- "corpus replay: ref {name} did not match its recorded expected value"
- )));
- }
- }
- }
- Ok(())
-}
-
-/// The set of every object reachable from `refs`' current tips over
-/// `objects` — a thin wrapper over [`gix_reachability::gc_mark`] (no
-/// reachability artifacts, so a plain walk) for a replay test to compare
-/// between the original backend and the one it replayed a corpus into.
-///
-/// # Errors
-///
-/// Returns an error if the ref or object store cannot be read, or the walk
-/// finds a ref tip whose history is incomplete.
-pub fn reachable_object_set(
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
-) -> gix_reachability::Result<BTreeSet<ObjectId>> {
- gix_reachability::gc_mark(refs, objects, &gix_reachability::ArtifactBundle::empty())
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "test fixture, not application code"
- )]
-
- use std::process::{Command, Stdio};
-
- use git_backend::RefName;
- use git_protocol::types::AppliedRefEdit;
- use git_store::test_support::{commit_all, head, repo};
-
- use super::*;
-
- /// Pack every object reachable from `commit` and not from `boundary`
- /// (or the commit's whole history, when `boundary` is `None`) — the
- /// same shape `git_hydrate::pre_receive::build_pack` produces for a
- /// real push, so this synthesized corpus exercises `replay_corpus`
- /// against realistic incremental packs, not just whole-history ones.
- fn pack_for(dir: &std::path::Path, commit: &str, boundary: Option<&str>) -> Vec<u8> {
- let mut rev_list_args = vec!["rev-list", "--objects", commit];
- if let Some(boundary) = boundary {
- rev_list_args.push("--not");
- rev_list_args.push(boundary);
- }
- let mut rev_list = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(&rev_list_args)
- .stdout(Stdio::piped())
- .spawn()
- .expect("spawn git rev-list");
- let pack_objects = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["pack-objects", "--stdout", "-q"])
- .stdin(rev_list.stdout.take().expect("rev-list stdout"))
- .stdout(Stdio::piped())
- .spawn()
- .expect("spawn git pack-objects");
- let output = pack_objects
- .wait_with_output()
- .expect("wait for pack-objects");
- assert!(rev_list.wait().expect("wait for rev-list").success());
- assert!(output.status.success());
- output.stdout
- }
-
- // @relation(role=Verifies)
- #[test]
- fn replays_a_synthesized_corpus_identically() {
- let dir = repo();
- std::fs::write(dir.path().join("file"), "one").expect("write fixture file");
- commit_all(dir.path(), "first");
- let commit1 = head(dir.path());
- std::fs::write(dir.path().join("file"), "two").expect("write fixture file");
- commit_all(dir.path(), "second");
- let commit2 = head(dir.path());
-
- let commit1_oid = ObjectId::from_hex(commit1.as_bytes()).expect("valid oid");
- let commit2_oid = ObjectId::from_hex(commit2.as_bytes()).expect("valid oid");
-
- let entries = vec![
- CorpusEntry::new(
- None,
- vec![AppliedRefEdit {
- name: RefName::new("refs/heads/main"),
- old: None,
- new: Some(commit1_oid),
- }],
- pack_for(dir.path(), &commit1, None),
- ),
- CorpusEntry::new(
- None,
- vec![AppliedRefEdit {
- name: RefName::new("refs/heads/main"),
- old: Some(commit1_oid),
- new: Some(commit2_oid),
- }],
- pack_for(dir.path(), &commit2, Some(&commit1)),
- ),
- ];
-
- let target = tempfile::tempdir().expect("tempdir");
- let status = Command::new("git")
- .arg("init")
- .arg("-q")
- .arg("--bare")
- .arg(target.path())
- .status()
- .expect("git init --bare");
- assert!(status.success());
- let refs = refstore_files::FilesRefStore::open(target.path()).expect("open refs");
- let objects = odb_files::OdbFiles::open(target.path()).expect("open objects");
-
- replay_corpus(&entries, &refs, &objects).expect("replay_corpus");
-
- let main = RefName::new("refs/heads/main");
- assert_eq!(refs.get(&main).expect("get"), Some(commit2_oid));
-
- let reachable = reachable_object_set(&refs, &objects).expect("reachable_object_set");
- assert!(reachable.contains(&commit1_oid));
- assert!(reachable.contains(&commit2_oid));
- }
-}
crates/backend-conformance/src/fixture_oids.rs
@@ -1,22 +1,0 @@
-//! [`FixtureOids`]: how `RefStore` property functions obtain object ids to
-//! write into `RefEdit`s.
-//!
-//! A gitoxide-backed `RefStore` resolves a ref by reading the object it
-//! targets (peeling through tags), so it needs oids of objects that
-//! actually exist in *its own* backing repository — an oid from an
-//! unrelated throwaway repo will not resolve. A backend that never touches
-//! object storage (e.g. a Postgres-backed one, per `docs/scale-out.adoc`)
-//! has no such requirement and can hand back any distinct synthetic value.
-//! Each backend's conformance instantiation says which it is by
-//! implementing this trait: [`crate::WithScratchRepo`] does it by
-//! committing directly into the scratch repository it holds.
-
-use gix_hash::ObjectId;
-
-/// Supplies distinct object ids a `RefStore` property function can use as
-/// `RefEdit` targets against this instance.
-pub trait FixtureOids {
- /// `n` distinct object ids safe to write into a `RefEdit` against this
- /// instance.
- fn fixture_oids(&self, n: usize) -> Vec<ObjectId>;
-}
crates/backend-conformance/src/lib.rs
@@ -1,46 +1,0 @@
-//! The backend conformance suite — the property tests that *are* the
-//! governing invariant (`docs/scale-out.adoc`, "Governing invariant"). One
-//! semantics, enforced by conformance: every [`git_backend::RefStore`] and
-//! [`git_backend::ObjectStore`] backend must pass the same properties,
-//! defined once here rather than per backend.
-//!
-//! # Plugging in a new backend
-//!
-//! Add `backend-conformance` as a dev-dependency and a small test file that
-//! calls [`ref_store_properties`] and/or [`object_store_properties`] with a
-//! closure that builds a fresh instance of the backend under test:
-//!
-//! ```ignore
-//! #[test]
-//! fn conforms_to_ref_store_properties() {
-//! backend_conformance::ref_store_properties(|| WithScratchRepo::new(FilesRefStore::open));
-//! }
-//! ```
-//!
-//! Every property is generic over the trait, not any one backend, so a type
-//! that satisfies `RefStore`/`ObjectStore` gets the whole suite for free.
-//! [`WithScratchRepo`] is a convenience for file-backed local backends that
-//! need a throwaway git repository to open against; a cloud backend's own
-//! instantiation builds its backend however it needs to and does not have
-//! to use it.
-
-mod collector;
-mod corpus;
-mod fixture_oids;
-mod object_store;
-mod ref_store;
-mod scratch_repo;
-mod support;
-
-pub use collector::{Collector, NoopCollector};
-pub use corpus::{reachable_object_set, replay_corpus};
-pub use fixture_oids::FixtureOids;
-pub use object_store::{
- causal_collection_safety, object_store_properties, quarantine_invisibility,
-};
-pub use ref_store::{
- multi_ref_all_or_nothing, multi_ref_cas_concurrent_conflict, prefix_iteration_consistency,
- ref_store_properties, reflog_records_transactions, watch_loss_tolerance,
-};
-pub use scratch_repo::WithScratchRepo;
-pub use support::{commit_oids_into, distinct_oids};
crates/backend-conformance/src/object_store.rs
@@ -1,96 +1,0 @@
-//! Property functions for [`git_backend::ObjectStore`] implementations —
-//! the same suite run against every backend (`docs/scale-out.adoc`,
-//! "Storage traits" / WS2).
-
-#![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "assertion helpers for a conformance suite, not application code"
-)]
-
-use git_backend::{ObjectStore, PackStream};
-
-use crate::collector::Collector;
-use crate::support::oid_and_pack;
-
-/// Run every [`ObjectStore`] property against a fresh backend built by
-/// `mk`, using `collector` to drive the causal-collection-safety property.
-/// Each property gets its own fresh backend instance (a fresh call to
-/// `mk`) so one property's writes never leak into another's assertions.
-pub fn object_store_properties<S, C>(mk: impl Fn() -> S, collector: &C)
-where
- S: ObjectStore,
- C: Collector,
-{
- quarantine_invisibility(&mk());
- causal_collection_safety(&mk(), collector);
-}
-
-/// Staged objects are invisible to `read`/`contains` until promoted; once
-/// promoted, they're visible and correct (`docs/scale-out.adoc`,
-/// "ObjectStore").
-pub fn quarantine_invisibility<S: ObjectStore>(store: &S) {
- let fixture = oid_and_pack();
-
- assert!(
- !store
- .contains(fixture.oid)
- .expect("contains before staging"),
- "a fresh store must not already contain the fixture object"
- );
-
- let quarantine = store
- .stage_pack(PackStream::new(std::io::Cursor::new(fixture.pack.clone())))
- .expect("stage_pack");
- assert!(
- !store.contains(fixture.oid).expect("contains while staged"),
- "a staged, unpromoted object must be invisible to contains"
- );
- assert!(
- store.read(fixture.oid).is_err(),
- "a staged, unpromoted object must be invisible to read"
- );
-
- store.promote(quarantine).expect("promote");
- assert!(
- store.contains(fixture.oid).expect("contains after promote"),
- "a promoted object must be visible to contains"
- );
- let object = store.read(fixture.oid).expect("read after promote");
- assert_eq!(object.kind, gix_object::Kind::Commit);
-}
-
-/// Objects staged for an in-flight transaction are never collected
-/// (`docs/scale-out.adoc`, correctness rule 1). `collector` stands in for
-/// whatever collection mechanism a backend has; today's local backends
-/// have none wired up ([`crate::NoopCollector`]), which still exercises
-/// the quarantine path a real collector must also respect, without
-/// asserting a collection arm no backend implements yet. A backend with a
-/// time-bounded staging grace window (`Collector::staging_grace`) is
-/// responsible for asserting its own boundary — that a session which can't
-/// finish inside the window aborts rather than becoming collectible
-/// mid-flight — in its own instantiation, since only it knows how to hold
-/// a staging session open past its deadline.
-pub fn causal_collection_safety<S: ObjectStore, C: Collector>(store: &S, collector: &C) {
- let fixture = oid_and_pack();
- let quarantine = store
- .stage_pack(PackStream::new(std::io::Cursor::new(fixture.pack.clone())))
- .expect("stage_pack");
-
- // A collection pass runs while the object is still only staged — the
- // in-flight transaction hasn't committed (promoted) yet.
- collector.collect();
-
- // Regardless of what the collector did, the staged object must still
- // be promotable and, once promoted, readable and correct: a collector
- // that reaped a staged object would make one of these fail.
- store
- .promote(quarantine)
- .expect("promote after a collection pass");
- assert!(
- store.contains(fixture.oid).expect("contains after promote"),
- "a collection pass during staging must not have reaped the staged object"
- );
- let object = store.read(fixture.oid).expect("read after promote");
- assert_eq!(object.kind, gix_object::Kind::Commit);
-}
crates/backend-conformance/src/ref_store.rs
@@ -1,304 +1,0 @@
-//! Property functions for [`git_backend::RefStore`] implementations — the
-//! same suite run against every backend (`docs/scale-out.adoc`, "Storage
-//! traits" / WS2).
-
-#![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "assertion helpers for a conformance suite, not application code"
-)]
-
-use std::sync::Arc;
-
-use git_backend::{Expected, RefEdit, RefName, RefStore, TxOutcome};
-use gix_hash::ObjectId;
-
-use crate::FixtureOids;
-
-/// Run every [`RefStore`] property against a fresh backend built by `mk`.
-/// Each property gets its own fresh backend instance (a fresh call to
-/// `mk`) so one property's writes never leak into another's assertions.
-pub fn ref_store_properties<S>(mk: impl Fn() -> S)
-where
- S: RefStore + FixtureOids + 'static,
-{
- multi_ref_all_or_nothing(&mk());
- prefix_iteration_consistency(&mk());
- reflog_records_transactions(&mk());
- watch_loss_tolerance(&mk());
- multi_ref_cas_concurrent_conflict(&mk);
-}
-
-/// One failing edit in a multi-ref transaction rejects the whole batch —
-/// no partial application (`docs/scale-out.adoc`, "RefStore": "Multi-ref
-/// compare-and-swap is in the contract").
-pub fn multi_ref_all_or_nothing<S: RefStore + FixtureOids>(store: &S) {
- let mut oids = store.fixture_oids(2).into_iter();
- let new_oid = oids.next().expect("first oid");
- let mismatched_oid = oids.next().expect("second oid");
-
- let a = RefName::new("refs/conformance/all-or-nothing/a");
- let b = RefName::new("refs/conformance/all-or-nothing/b");
-
- // `b`'s precondition already fails (it doesn't exist), so `a` must not
- // apply either, even though its own precondition holds.
- let edits = [
- RefEdit {
- name: a.clone(),
- expected: Expected::MustNotExist,
- new: Some(new_oid),
- },
- RefEdit {
- name: b.clone(),
- expected: Expected::MustExistAndMatch(mismatched_oid),
- new: Some(new_oid),
- },
- ];
- let outcome = store.transaction(&edits).expect("transaction");
- assert!(
- matches!(outcome, TxOutcome::Rejected { .. }),
- "a batch with one failing precondition must reject the whole transaction"
- );
- assert_eq!(
- store.get(&a).expect("get a"),
- None,
- "a rejected batch must not partially apply — a's own edit had a valid precondition but must not have landed"
- );
- assert_eq!(store.get(&b).expect("get b"), None);
-}
-
-/// `iter_prefix` agrees with `get` after transactions land — additions and
-/// deletions alike.
-pub fn prefix_iteration_consistency<S: RefStore + FixtureOids>(store: &S) {
- let mut oids = store.fixture_oids(2).into_iter();
- let inside_oid = oids.next().expect("first oid");
- let outside_oid = oids.next().expect("second oid");
-
- let inside = RefName::new("refs/conformance/prefix/inside");
- let outside = RefName::new("refs/conformance/other/outside");
- let prefix = RefName::new("refs/conformance/prefix/");
-
- store
- .transaction(&[
- RefEdit {
- name: inside.clone(),
- expected: Expected::MustNotExist,
- new: Some(inside_oid),
- },
- RefEdit {
- name: outside.clone(),
- expected: Expected::MustNotExist,
- new: Some(outside_oid),
- },
- ])
- .expect("transaction");
-
- let listed: Vec<_> = store
- .iter_prefix(&prefix)
- .expect("iter_prefix")
- .map(|item| item.expect("ref entry"))
- .collect();
- assert_eq!(
- listed,
- vec![(inside.clone(), inside_oid)],
- "iter_prefix must list exactly the refs under the prefix, agreeing with get"
- );
- assert_eq!(store.get(&inside).expect("get inside"), Some(inside_oid));
-
- // Delete it via a transaction; iter_prefix must reflect the deletion.
- store
- .transaction(&[RefEdit {
- name: inside.clone(),
- expected: Expected::MustExistAndMatch(inside_oid),
- new: None,
- }])
- .expect("delete transaction");
- let listed_after_delete: Vec<_> = store
- .iter_prefix(&prefix)
- .expect("iter_prefix after delete")
- .collect();
- assert!(
- listed_after_delete.is_empty(),
- "iter_prefix must not list a ref deleted by a transaction"
- );
- assert_eq!(store.get(&inside).expect("get after delete"), None);
-}
-
-/// A transaction appends to the ref's log.
-pub fn reflog_records_transactions<S: RefStore + FixtureOids>(store: &S) {
- let oid = store.fixture_oids(1).into_iter().next().expect("oid");
- let name = RefName::new("refs/conformance/reflog/probe");
- store
- .transaction(&[RefEdit {
- name: name.clone(),
- expected: Expected::MustNotExist,
- new: Some(oid),
- }])
- .expect("transaction");
-
- let entries: Vec<_> = store
- .log(&name)
- .expect("log")
- .map(|entry| entry.expect("log entry"))
- .collect();
- assert!(
- !entries.is_empty(),
- "a transaction must append to the ref's log"
- );
- let latest = entries.first().expect("at least one entry");
- assert_eq!(latest.new, Some(oid));
-}
-
-/// `watch` is a hint only. Killing the event stream mid-flight (dropping it
-/// before it delivers anything) must never lose the underlying ref state:
-/// transactions still land and later reads are still correct
-/// (`docs/scale-out.adoc`, "RefStore"). Queue-table recovery on reconnect
-/// is a cloud backend's own concern; this asserts the backend-independent
-/// half — no *state* is lost when the channel drops.
-pub fn watch_loss_tolerance<S: RefStore + FixtureOids>(store: &S) {
- let mut oids = store.fixture_oids(2).into_iter();
- let first_oid = oids.next().expect("first oid");
- let second_oid = oids.next().expect("second oid");
-
- let prefix = RefName::new("refs/conformance/watch-loss/");
- let first = RefName::new("refs/conformance/watch-loss/probe-1");
- let second = RefName::new("refs/conformance/watch-loss/probe-2");
-
- // Open a watcher, then drop it immediately — simulating a connection
- // that dies mid-flight before it delivers anything.
- let watcher = store.watch(&prefix).expect("watch");
- drop(watcher);
-
- store
- .transaction(&[RefEdit {
- name: first.clone(),
- expected: Expected::MustNotExist,
- new: Some(first_oid),
- }])
- .expect("transaction after dropping the watcher");
- assert_eq!(
- store.get(&first).expect("get after watch drop"),
- Some(first_oid),
- "a transaction must land correctly even though its watcher was dropped before delivery"
- );
-
- // A fresh watch opened after the drop must still see subsequent
- // changes: the earlier drop must not have wedged the watch mechanism.
- let watcher = store.watch(&prefix).expect("watch again");
- store
- .transaction(&[RefEdit {
- name: second.clone(),
- expected: Expected::MustNotExist,
- new: Some(second_oid),
- }])
- .expect("second transaction");
- // Best-effort: the hint arriving is not required (that's the whole
- // point of "hint only"); re-reading afterward must be correct
- // regardless of whether it did.
- let _hint = watcher.recv_timeout(std::time::Duration::from_millis(200));
- assert_eq!(
- store.get(&second).expect("get after second transaction"),
- Some(second_oid)
- );
-}
-
-/// Concurrent, conflicting multi-ref transactions: exactly one wins, no
-/// partial application, and every loser is `Rejected` rather than causing
-/// corruption (`docs/scale-out.adoc`, "RefStore").
-pub fn multi_ref_cas_concurrent_conflict<S>(mk: &impl Fn() -> S)
-where
- S: RefStore + FixtureOids + 'static,
-{
- const CONTENDERS: usize = 8;
-
- let store = Arc::new(mk());
- let a = RefName::new("refs/conformance/cas-race/a");
- let b = RefName::new("refs/conformance/cas-race/b");
-
- let mut oids = store.fixture_oids(CONTENDERS.saturating_add(1)).into_iter();
- let baseline = oids.next().expect("baseline oid");
- let candidates: Vec<ObjectId> = oids.collect();
-
- store
- .transaction(&[
- RefEdit {
- name: a.clone(),
- expected: Expected::MustNotExist,
- new: Some(baseline),
- },
- RefEdit {
- name: b.clone(),
- expected: Expected::MustNotExist,
- new: Some(baseline),
- },
- ])
- .expect("seed transaction");
-
- let handles: Vec<_> = candidates
- .into_iter()
- .map(|candidate| {
- let store = Arc::clone(&store);
- let (a, b) = (a.clone(), b.clone());
- std::thread::spawn(move || {
- let outcome = store
- .transaction(&[
- RefEdit {
- name: a,
- expected: Expected::MustExistAndMatch(baseline),
- new: Some(candidate),
- },
- RefEdit {
- name: b,
- expected: Expected::MustExistAndMatch(baseline),
- new: Some(candidate),
- },
- ])
- .expect("contender transaction");
- (outcome, candidate)
- })
- })
- .collect();
-
- let outcomes: Vec<(TxOutcome, ObjectId)> = handles
- .into_iter()
- .map(|handle| handle.join().expect("contender thread panicked"))
- .collect();
-
- let applied = outcomes
- .iter()
- .filter(|(outcome, _)| matches!(outcome, TxOutcome::Applied))
- .count();
- assert_eq!(
- applied, 1,
- "exactly one conflicting concurrent multi-ref transaction must win the CAS race"
- );
- let rejected = outcomes
- .iter()
- .filter(|(outcome, _)| matches!(outcome, TxOutcome::Rejected { .. }))
- .count();
- assert_eq!(
- rejected,
- CONTENDERS.saturating_sub(1),
- "every losing contender must be Rejected, not corrupted or silently dropped"
- );
-
- let winner = outcomes
- .iter()
- .find_map(|(outcome, candidate)| {
- matches!(outcome, TxOutcome::Applied).then_some(*candidate)
- })
- .expect("exactly one applied outcome");
-
- let final_a = store.get(&a).expect("get a after race");
- let final_b = store.get(&b).expect("get b after race");
- assert_eq!(
- final_a,
- Some(winner),
- "ref a must reflect the single winning multi-ref transaction"
- );
- assert_eq!(
- final_b,
- Some(winner),
- "ref b must move together with a — no partial application under concurrency"
- );
-}
crates/backend-conformance/src/scratch_repo.rs
@@ -1,90 +1,0 @@
-//! [`WithScratchRepo`]: keeps a file-backed backend alive alongside the
-//! scratch git repository its on-disk state depends on.
-
-#![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "fixture helper for a conformance suite, not application code"
-)]
-
-use std::path::Path;
-
-use git_backend::{
- Object, ObjectStore, PackStream, QuarantineId, RefEdit, RefEventStream, RefIter, RefLogIter,
- RefName, RefStore, Result, TxOutcome,
-};
-use gix_hash::ObjectId;
-
-use crate::FixtureOids;
-use crate::support::commit_oids_into;
-
-/// Bundles a backend with the throwaway git repository it was opened
-/// against, so the repository outlives the backend: struct fields drop in
-/// declaration order, so `store` (which may hold open handles into the
-/// repository) is released before `_dir` deletes it.
-///
-/// Local file-backed backends (`refstore-files`, `odb-files`) need a real
-/// repository on disk to open against; this is a `mk` closure's return
-/// value in a conformance instantiation for that shape of backend. Cloud
-/// backends have no such requirement and do not need this type.
-pub struct WithScratchRepo<S> {
- store: S,
- _dir: tempfile::TempDir,
-}
-
-impl<S> WithScratchRepo<S> {
- /// Create a fresh scratch git repository and hand its path to `open` to
- /// build the backend, keeping the repository alive for as long as the
- /// returned value lives.
- pub fn new<E: std::fmt::Debug>(open: impl FnOnce(&Path) -> std::result::Result<S, E>) -> Self {
- let dir = git_store::test_support::repo();
- let store = open(dir.path()).expect("open backend against scratch repo");
- Self { store, _dir: dir }
- }
-}
-
-impl<S: RefStore> RefStore for WithScratchRepo<S> {
- fn get(&self, name: &RefName) -> Result<Option<ObjectId>> {
- self.store.get(name)
- }
-
- fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter> {
- self.store.iter_prefix(prefix)
- }
-
- fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> {
- self.store.transaction(edits)
- }
-
- fn watch(&self, prefix: &RefName) -> Result<RefEventStream> {
- self.store.watch(prefix)
- }
-
- fn log(&self, name: &RefName) -> Result<RefLogIter> {
- self.store.log(name)
- }
-}
-
-impl<S> FixtureOids for WithScratchRepo<S> {
- fn fixture_oids(&self, n: usize) -> Vec<ObjectId> {
- commit_oids_into(self._dir.path(), n)
- }
-}
-
-impl<S: ObjectStore> ObjectStore for WithScratchRepo<S> {
- fn read(&self, id: ObjectId) -> Result<Object> {
- self.store.read(id)
- }
-
- fn contains(&self, id: ObjectId) -> Result<bool> {
- self.store.contains(id)
- }
-
- fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId> {
- self.store.stage_pack(pack)
- }
-
- fn promote(&self, q: QuarantineId) -> Result<()> {
- self.store.promote(q)
- }
-}
crates/backend-conformance/src/support.rs
@@ -1,91 +1,0 @@
-//! Shared fixtures: distinct real commit oids, and a valid pack built the
-//! same way a push transmits one. Property functions exercise backends
-//! against real git object bytes rather than synthetic hashes, since a
-//! backend is free to validate what it's handed and no real pack could
-//! ever contain a made-up hash's "content".
-
-#![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "fixture helpers for a conformance suite, not application code"
-)]
-
-use std::path::Path;
-use std::process::{Command, Stdio};
-
-use git_store::test_support::{commit_all, head, repo};
-use gix_hash::ObjectId;
-
-/// `n` distinct, real commit object ids, built by committing `n` times in
-/// a throwaway repository unrelated to any backend under test. Only usable
-/// as `RefEdit` targets for a `RefStore` that never dereferences into
-/// object storage (e.g. a Postgres-backed one); a backend that resolves a
-/// ref by reading its target object needs [`commit_oids_into`] instead,
-/// since this repository is not the one it reads from.
-pub fn distinct_oids(n: usize) -> Vec<ObjectId> {
- let dir = repo();
- commit_oids_into(dir.path(), n)
-}
-
-/// `n` distinct, real commit object ids, built by committing `n` times
-/// into the already-initialized repository at `path`. For a `RefStore`
-/// backend that peels a ref by reading its target object (gitoxide-backed
-/// ones do), `path` must be the same repository the backend was opened
-/// against, so the objects a `RefEdit` points at actually resolve.
-pub fn commit_oids_into(path: &Path, n: usize) -> Vec<ObjectId> {
- (0..n)
- .map(|i| {
- std::fs::write(path.join("file"), i.to_string()).expect("write fixture file");
- commit_all(path, &format!("conformance fixture {i}"));
- let hex = head(path);
- ObjectId::from_hex(hex.as_bytes()).expect("valid oid hex")
- })
- .collect()
-}
-
-/// A real commit oid, and a pack containing it and everything it reaches.
-pub struct PackFixture {
- /// The commit at the tip of [`PackFixture::pack`].
- pub oid: ObjectId,
- /// A pack containing `oid` and everything it reaches.
- pub pack: Vec<u8>,
-}
-
-/// Build a [`PackFixture`]: one commit in a fresh throwaway repository,
-/// packed on its own.
-pub fn oid_and_pack() -> PackFixture {
- let dir = repo();
- std::fs::write(dir.path().join("file"), b"content").expect("write fixture file");
- commit_all(dir.path(), "conformance fixture");
- let hex = head(dir.path());
- let oid = ObjectId::from_hex(hex.as_bytes()).expect("valid oid hex");
- let pack = pack_for(dir.path(), &hex);
- PackFixture { oid, pack }
-}
-
-/// Pack `commit` and everything it reaches from `dir`, by shelling out to
-/// `git rev-list`/`git pack-objects` — the same bytes a real push
-/// transmits, mirroring `odb-files`'s own test fixture.
-fn pack_for(dir: &Path, commit: &str) -> Vec<u8> {
- let mut rev_list = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["rev-list", "--objects", commit])
- .stdout(Stdio::piped())
- .spawn()
- .expect("spawn git rev-list");
- let pack_objects = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["pack-objects", "--stdout", "-q"])
- .stdin(rev_list.stdout.take().expect("rev-list stdout"))
- .stdout(Stdio::piped())
- .spawn()
- .expect("spawn git pack-objects");
- let output = pack_objects
- .wait_with_output()
- .expect("wait for pack-objects");
- assert!(rev_list.wait().expect("wait for rev-list").success());
- assert!(output.status.success());
- output.stdout
-}
crates/effect-dispatcher/Cargo.toml
@@ -1,17 +1,0 @@
-[package]
-name = "effect-dispatcher"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-git-backend = { workspace = true }
-gix-hash = { workspace = true }
-refstore-postgres = { workspace = true }
-
-[dev-dependencies]
-uuid = { workspace = true }
-
-[lints]
-workspace = true
crates/effect-dispatcher/src/job.rs
@@ -1,173 +1,0 @@
-//! The queued work order: one effect plus its materialized inputs, encoded
-//! as the text `git_ents_effect_queue.payload` carries. A hand-rolled line
-//! format (like `git-effect`'s job files) rather than a serialization
-//! dependency, per the dependency policy.
-//!
-//! One `key value` pair per line; `command` — the only field that can
-//! legitimately contain newlines — is escaped ([`escape`]/[`unescape`]).
-//! A payload [`decode`] cannot read is a poison row: the dispatcher
-//! completes it without running anything (mirroring how
-//! `git_effect::engine` drops a malformed job file), rather than retrying
-//! it forever.
-
-use std::collections::BTreeMap;
-
-use git_backend::{EffectDef, MaterializedInputs};
-use gix_hash::ObjectId;
-
-/// One queue row's decoded work order.
-#[derive(Debug, Clone)]
-pub struct Job {
- /// What to run.
- pub effect: EffectDef,
- /// The materialized inputs to run it against.
- pub inputs: MaterializedInputs,
-}
-
-/// Encode `job` as the queue payload text [`decode`] reads.
-#[must_use]
-pub fn encode(job: &Job) -> String {
- let mut out = String::new();
- out.push_str(&format!("name {}\n", job.effect.name));
- if let Some(command) = &job.effect.command {
- out.push_str(&format!("command {}\n", escape(command)));
- }
- if let Some(image) = &job.effect.image {
- out.push_str(&format!("image {image}\n"));
- }
- out.push_str(&format!("tree {}\n", job.inputs.tree));
- for (name, path) in &job.inputs.toolchain_paths {
- out.push_str(&format!("toolchain {name} {path}\n"));
- }
- if let Some(cache) = &job.inputs.cache {
- out.push_str(&format!("cache {cache}\n"));
- }
- out
-}
-
-/// Decode a queue payload, or `None` when it is malformed (an unknown key,
-/// a missing `name`/`tree`, an invalid tree oid).
-#[must_use]
-pub fn decode(payload: &str) -> Option<Job> {
- let mut name = None;
- let mut command = None;
- let mut image = None;
- let mut tree = None;
- let mut toolchain_paths = BTreeMap::new();
- let mut cache = None;
- for line in payload.lines() {
- if line.is_empty() {
- continue;
- }
- let (key, rest) = line.split_once(' ')?;
- match key {
- "name" => name = Some(rest.to_owned()),
- "command" => command = Some(unescape(rest)),
- "image" => image = Some(rest.to_owned()),
- "tree" => tree = Some(ObjectId::from_hex(rest.as_bytes()).ok()?),
- "toolchain" => {
- let (toolchain, path) = rest.split_once(' ')?;
- toolchain_paths.insert(toolchain.to_owned(), path.to_owned());
- }
- "cache" => cache = Some(rest.to_owned()),
- _ => return None,
- }
- }
- Some(Job {
- effect: EffectDef {
- name: name?,
- command,
- image,
- },
- inputs: MaterializedInputs {
- tree: tree?,
- toolchain_paths,
- cache,
- },
- })
-}
-
-/// Escape backslashes and newlines so a multi-line command survives the
-/// one-pair-per-line format.
-fn escape(value: &str) -> String {
- value.replace('\\', "\\\\").replace('\n', "\\n")
-}
-
-/// Invert [`escape`].
-fn unescape(value: &str) -> String {
- let mut out = String::with_capacity(value.len());
- let mut chars = value.chars();
- while let Some(c) = chars.next() {
- if c != '\\' {
- out.push(c);
- continue;
- }
- match chars.next() {
- Some('n') => out.push('\n'),
- Some(other) => out.push(other),
- None => out.push('\\'),
- }
- }
- out
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, reason = "unit test")]
-
- use super::*;
-
- fn tree() -> ObjectId {
- ObjectId::from_hex(b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap()
- }
-
- #[test]
- fn a_job_round_trips_through_the_payload_format() {
- let mut toolchain_paths = BTreeMap::new();
- toolchain_paths.insert("rust".to_owned(), "/toolchains/aaa/bin".to_owned());
- let job = Job {
- effect: EffectDef {
- name: "test".to_owned(),
- command: Some("cargo fmt --check\ncargo test".to_owned()),
- image: Some("debian:stable-slim".to_owned()),
- },
- inputs: MaterializedInputs {
- tree: tree(),
- toolchain_paths,
- cache: Some("sccache".to_owned()),
- },
- };
- let decoded = decode(&encode(&job)).unwrap();
- assert_eq!(decoded.effect, job.effect);
- assert_eq!(decoded.inputs.tree, job.inputs.tree);
- assert_eq!(decoded.inputs.toolchain_paths, job.inputs.toolchain_paths);
- assert_eq!(decoded.inputs.cache, job.inputs.cache);
- }
-
- #[test]
- fn a_minimal_job_round_trips() {
- let job = Job {
- effect: EffectDef {
- name: "test".to_owned(),
- command: None,
- image: None,
- },
- inputs: MaterializedInputs {
- tree: tree(),
- toolchain_paths: BTreeMap::new(),
- cache: None,
- },
- };
- let decoded = decode(&encode(&job)).unwrap();
- assert_eq!(decoded.effect, job.effect);
- assert_eq!(decoded.inputs.cache, None);
- }
-
- #[test]
- fn malformed_payloads_decode_to_none() {
- assert!(decode("").is_none());
- assert!(decode("name only-a-name\n").is_none()); // no tree
- assert!(decode("tree bbbb\nname x\n").is_none()); // bad oid
- assert!(decode("unknown key\n").is_none());
- }
-}
crates/effect-dispatcher/src/lib.rs
@@ -1,654 +1,0 @@
-//! The WS7 effect dispatcher (`docs/scale-out.adoc`, "WS7 — Effects and
-//! Sprites"): one small machine that drains the Postgres effect queue and
-//! spawns each claimed effect through an injected
-//! [`git_backend::EffectExecutor`] — `exec-local` in a local deployment,
-//! `exec-sprites` hosted; the loop cannot tell which, and must not be able
-//! to (application code branches on trait capabilities, never on
-//! deployment identity).
-//!
-//! # At-least-once, from the queue table
-//!
-//! [`git_backend::RefStore::watch`] is a wakeup *hint* only. Every wakeup —
-//! a watch hint, a worker slot freeing up, or the periodic poll — runs a
-//! full [`Dispatcher::tick`], which requeues stale claims and then claims
-//! until the queue is empty or a cap is hit; the poll doubles as the
-//! reconnect backstop the watch contract demands, so a dropped
-//! LISTEN/NOTIFY notification delays a drain by at most one
-//! [`DispatcherConfig::poll_interval`], never loses one. Claims carry a
-//! claimant and timestamp; a claim older than
-//! [`DispatcherConfig::claim_timeout`] is returned to the queue, so a
-//! dispatcher that dies mid-effect redelivers rather than loses — possibly
-//! running an effect twice, which is the at-least-once trade: effects are
-//! recorded per commit, so a duplicate run re-records the same outcome.
-//!
-//! # Caps
-//!
-//! Two knobs bound concurrency: a global cap (cost — every running effect
-//! is a machine or a container) and a per-repo cap (fairness — one
-//! repository's backlog must not starve the rest). Both are enforced
-//! exactly: the drain claims one row per query, recomputing the exclusion
-//! set (repositories at their per-repo cap) between claims, so a burst
-//! from one repository can never overshoot its cap inside a single batch.
-//! One `UPDATE … SKIP LOCKED` round trip per claimed effect is cheap next
-//! to what an effect costs to run.
-
-pub mod job;
-mod queue;
-
-pub use queue::{EffectQueue, QueuedJob};
-
-use std::collections::HashMap;
-use std::sync::mpsc::{Receiver, Sender};
-use std::sync::{Arc, Mutex as StdMutex, PoisonError};
-use std::time::Duration;
-
-use git_backend::{EffectExecutor, RefEventStream};
-
-/// The dispatcher's knobs. Both caps are enforced exactly (see the crate
-/// docs on claiming one row at a time).
-#[derive(Debug, Clone)]
-pub struct DispatcherConfig {
- /// The most effects running at once across every repository (cost).
- pub global_cap: usize,
- /// The most effects running at once for one repository (fairness).
- pub per_repo_cap: usize,
- /// How old a claim must be before [`Dispatcher::tick`] returns it to
- /// the queue. Must comfortably exceed the longest legitimate effect
- /// run (the executors' own timeout is 30 minutes), or a slow effect is
- /// redelivered while still running.
- pub claim_timeout: Duration,
- /// The periodic-poll interval: the ceiling on how long a dropped watch
- /// hint can delay a drain.
- pub poll_interval: Duration,
- /// Warm-pool size — always 0 today, and nothing implements a warm pool
- /// beyond this knob. Q3 (`docs/scale-out.adoc`): revisit only if
- /// measured Sprite cold start (image pull included) is *not* ≪ effect
- /// duration; until that measurement exists, a warm pool is cost
- /// without evidence.
- pub warm_pool: usize,
-}
-
-impl Default for DispatcherConfig {
- fn default() -> Self {
- Self {
- global_cap: 8,
- per_repo_cap: 2,
- claim_timeout: Duration::from_secs(45 * 60),
- poll_interval: Duration::from_secs(10),
- warm_pool: 0,
- }
- }
-}
-
-/// In-flight accounting: how many effects are running globally and per
-/// repository. Updated when a worker starts and when it settles; the drain
-/// derives its claim budget and exclusion set from it.
-#[derive(Debug, Default)]
-struct Running {
- global: usize,
- per_repo: HashMap<String, usize>,
-}
-
-/// The dispatcher loop: [`Dispatcher::run`] forever in production,
-/// [`Dispatcher::tick`] once per wakeup (and directly from tests).
-pub struct Dispatcher {
- queue: Arc<dyn EffectQueue>,
- executor: Arc<dyn EffectExecutor>,
- config: DispatcherConfig,
- claimed_by: String,
- running: Arc<StdMutex<Running>>,
- wake_tx: Sender<()>,
- wake_rx: StdMutex<Receiver<()>>,
-}
-
-/// Lock `mutex`, recovering the guard from a poisoned lock rather than
-/// panicking: losing one wakeup or one count to a poisoned lock is
-/// recoverable (the periodic poll re-drains); tearing the dispatcher down
-/// is not.
-fn lock<T>(mutex: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
- mutex.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-impl Dispatcher {
- /// A dispatcher draining `queue` into `executor` under `config`'s caps.
- #[must_use]
- pub fn new(
- queue: Arc<dyn EffectQueue>,
- executor: Arc<dyn EffectExecutor>,
- config: DispatcherConfig,
- ) -> Self {
- let (wake_tx, wake_rx) = std::sync::mpsc::channel();
- Self {
- queue,
- executor,
- config,
- claimed_by: format!("dispatcher-{}", std::process::id()),
- running: Arc::new(StdMutex::new(Running::default())),
- wake_tx,
- wake_rx: StdMutex::new(wake_rx),
- }
- }
-
- /// Run forever: drain now, then re-drain on every wakeup — a `hints`
- /// event, a worker slot freeing up, or the periodic poll (the
- /// reconnect backstop; see the crate docs).
- pub fn run(&self, hints: RefEventStream) -> ! {
- let forward = self.wake_tx.clone();
- std::thread::spawn(move || {
- while hints.recv().is_some() {
- if forward.send(()).is_err() {
- break;
- }
- }
- });
- let wake_rx = lock(&self.wake_rx);
- loop {
- self.tick();
- // A hint, a completion, or the poll timeout: which one woke us
- // is deliberately not distinguished — every wakeup re-drains.
- let _wakeup = wake_rx.recv_timeout(self.config.poll_interval);
- }
- }
-
- /// One full drain: requeue stale claims, then claim-and-start until
- /// the queue is empty or a cap is hit. Idempotent and safe to call on
- /// every wakeup; claims one row per query so both caps are exact (see
- /// the crate docs).
- pub fn tick(&self) {
- if let Err(e) = self.queue.requeue_stale(self.config.claim_timeout) {
- eprintln!("dispatcher: could not requeue stale claims: {e}");
- }
- loop {
- let exclude = {
- let running = lock(&self.running);
- if running.global >= self.config.global_cap {
- return;
- }
- running
- .per_repo
- .iter()
- .filter(|(_, count)| **count >= self.config.per_repo_cap)
- .map(|(repo, _)| repo.clone())
- .collect::<Vec<_>>()
- };
- let claimed = match self.queue.claim(&self.claimed_by, 1, &exclude) {
- Ok(claimed) => claimed,
- Err(e) => {
- eprintln!("dispatcher: could not claim from the queue: {e}");
- return;
- }
- };
- let Some(claimed_job) = claimed.into_iter().next() else {
- return;
- };
- self.start(claimed_job);
- }
- }
-
- /// Decode and spawn one claimed row, handing its wait to a worker
- /// thread that completes the row and frees the slot when the effect
- /// settles.
- ///
- /// Failure semantics, per the at-least-once contract:
- /// - an *undecodable* payload is poison: completed immediately, never
- /// retried (mirroring how the engine drops a malformed job file);
- /// - a payload that decodes but will not `spawn` (the sandbox is down,
- /// the launcher errored) stays `claimed`, so the stale-claim timeout
- /// redelivers it — the work never started, so redelivery is safe;
- /// - a spawned effect is completed once `wait` settles, *whatever* it
- /// settles to: an executor error after the spawn is a recorded
- /// outcome, not grounds to run the effect again in-process.
- fn start(&self, claimed_job: QueuedJob) {
- let Some(work) = job::decode(&claimed_job.payload) else {
- eprintln!(
- "dispatcher: dropping malformed payload on queue row {} ({})",
- claimed_job.id, claimed_job.repo
- );
- if let Err(e) = self.queue.complete(claimed_job.id) {
- eprintln!(
- "dispatcher: could not complete poison row {}: {e}",
- claimed_job.id
- );
- }
- return;
- };
- let handle = match self.executor.spawn(&work.effect, work.inputs) {
- Ok(handle) => handle,
- Err(e) => {
- eprintln!(
- "dispatcher: could not spawn {} for {} (left claimed for redelivery): {e}",
- work.effect.name, claimed_job.repo
- );
- return;
- }
- };
-
- {
- let mut running = lock(&self.running);
- running.global = running.global.saturating_add(1);
- let count = running
- .per_repo
- .entry(claimed_job.repo.clone())
- .or_insert(0);
- *count = count.saturating_add(1);
- }
-
- let queue = Arc::clone(&self.queue);
- let executor = Arc::clone(&self.executor);
- let running = Arc::clone(&self.running);
- let wake = self.wake_tx.clone();
- let effect_name = work.effect.name;
- std::thread::spawn(move || {
- match executor.wait(&handle) {
- Ok(status) => eprintln!(
- "dispatcher: {effect_name} settled for {}: {status:?}",
- claimed_job.repo
- ),
- Err(e) => eprintln!(
- "dispatcher: could not observe {effect_name} for {}: {e}",
- claimed_job.repo
- ),
- }
- if let Err(e) = queue.complete(claimed_job.id) {
- eprintln!(
- "dispatcher: could not complete queue row {}: {e}",
- claimed_job.id
- );
- }
- {
- let mut running = lock(&running);
- running.global = running.global.saturating_sub(1);
- if let Some(count) = running.per_repo.get_mut(&claimed_job.repo) {
- *count = count.saturating_sub(1);
- if *count == 0 {
- running.per_repo.remove(&claimed_job.repo);
- }
- }
- }
- // A slot freed: wake the loop so remaining queue rows are
- // claimed now, not on the next poll.
- let _woken = wake.send(());
- });
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, reason = "unit test")]
-
- use std::collections::{BTreeMap, HashSet};
- use std::sync::Condvar;
- use std::time::Instant;
-
- use git_backend::{EffectDef, EffectHandle, EffectStatus, MaterializedInputs};
-
- use super::*;
-
- /// Poll `condition` for up to five seconds — worker settlement runs on
- /// its own threads, so assertions on it are eventual.
- fn eventually(condition: impl Fn() -> bool) -> bool {
- let deadline = Instant::now().checked_add(Duration::from_secs(5)).unwrap();
- while Instant::now() < deadline {
- if condition() {
- return true;
- }
- std::thread::sleep(Duration::from_millis(5));
- }
- condition()
- }
-
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- enum State {
- Enqueued,
- Claimed,
- Done,
- }
-
- #[derive(Debug, Clone)]
- struct Row {
- id: i64,
- repo: String,
- payload: String,
- state: State,
- claimed_at: Option<Instant>,
- claimed_by: Option<String>,
- }
-
- /// In-memory [`EffectQueue`] with the table's exact state machine.
- struct FakeQueue {
- rows: StdMutex<Vec<Row>>,
- }
-
- impl FakeQueue {
- fn new(rows: Vec<Row>) -> Self {
- Self {
- rows: StdMutex::new(rows),
- }
- }
-
- fn states(&self) -> Vec<State> {
- lock(&self.rows).iter().map(|row| row.state).collect()
- }
-
- fn all_done(&self) -> bool {
- self.states().iter().all(|state| *state == State::Done)
- }
- }
-
- impl EffectQueue for FakeQueue {
- fn claim(
- &self,
- claimed_by: &str,
- limit: usize,
- exclude_repos: &[String],
- ) -> git_backend::Result<Vec<QueuedJob>> {
- let mut rows = lock(&self.rows);
- let mut out = Vec::new();
- for row in rows.iter_mut() {
- if out.len() >= limit {
- break;
- }
- if row.state == State::Enqueued && !exclude_repos.contains(&row.repo) {
- row.state = State::Claimed;
- row.claimed_at = Some(Instant::now());
- row.claimed_by = Some(claimed_by.to_owned());
- out.push(QueuedJob {
- id: row.id,
- repo: row.repo.clone(),
- payload: row.payload.clone(),
- });
- }
- }
- Ok(out)
- }
-
- fn complete(&self, id: i64) -> git_backend::Result<()> {
- for row in lock(&self.rows).iter_mut() {
- if row.id == id {
- row.state = State::Done;
- }
- }
- Ok(())
- }
-
- fn requeue_stale(&self, older_than: Duration) -> git_backend::Result<u64> {
- let mut requeued = 0u64;
- for row in lock(&self.rows).iter_mut() {
- let stale = row.state == State::Claimed
- && row.claimed_at.is_none_or(|at| at.elapsed() > older_than);
- if stale {
- row.state = State::Enqueued;
- row.claimed_at = None;
- row.claimed_by = None;
- requeued = requeued.saturating_add(1);
- }
- }
- Ok(requeued)
- }
- }
-
- /// [`EffectExecutor`] whose spawns are recorded and whose completions
- /// the test releases one by one.
- struct FakeExecutor {
- started: StdMutex<Vec<String>>,
- released: StdMutex<HashSet<String>>,
- settle: Condvar,
- }
-
- impl FakeExecutor {
- fn new() -> Self {
- Self {
- started: StdMutex::new(Vec::new()),
- released: StdMutex::new(HashSet::new()),
- settle: Condvar::new(),
- }
- }
-
- fn started(&self) -> Vec<String> {
- lock(&self.started).clone()
- }
-
- fn release(&self, name: &str) {
- lock(&self.released).insert(name.to_owned());
- self.settle.notify_all();
- }
- }
-
- impl EffectExecutor for FakeExecutor {
- fn spawn(
- &self,
- effect: &EffectDef,
- _inputs: MaterializedInputs,
- ) -> git_backend::Result<EffectHandle> {
- lock(&self.started).push(effect.name.clone());
- Ok(EffectHandle {
- id: effect.name.clone(),
- })
- }
-
- fn wait(&self, handle: &EffectHandle) -> git_backend::Result<EffectStatus> {
- let deadline = Duration::from_secs(5);
- let mut released = lock(&self.released);
- while !released.contains(&handle.id) {
- let (guard, timeout) = self
- .settle
- .wait_timeout(released, deadline)
- .unwrap_or_else(PoisonError::into_inner);
- released = guard;
- if timeout.timed_out() {
- return Err(git_backend::Error::Effect(format!(
- "test effect {} was never released",
- handle.id
- )));
- }
- }
- Ok(EffectStatus::Pass)
- }
- }
-
- fn payload(name: &str) -> String {
- job::encode(&job::Job {
- effect: EffectDef {
- name: name.to_owned(),
- command: Some("true".to_owned()),
- image: None,
- },
- inputs: MaterializedInputs {
- tree: gix_hash::ObjectId::from_hex(b"cccccccccccccccccccccccccccccccccccccccc")
- .unwrap(),
- toolchain_paths: BTreeMap::new(),
- cache: None,
- },
- })
- }
-
- fn row(id: i64, repo: &str, name: &str) -> Row {
- Row {
- id,
- repo: repo.to_owned(),
- payload: payload(name),
- state: State::Enqueued,
- claimed_at: None,
- claimed_by: None,
- }
- }
-
- fn dispatcher(
- rows: Vec<Row>,
- config: DispatcherConfig,
- ) -> (Dispatcher, Arc<FakeQueue>, Arc<FakeExecutor>) {
- let queue = Arc::new(FakeQueue::new(rows));
- let executor = Arc::new(FakeExecutor::new());
- let dispatcher = Dispatcher::new(
- Arc::clone(&queue) as Arc<dyn EffectQueue>,
- Arc::clone(&executor) as Arc<dyn EffectExecutor>,
- config,
- );
- (dispatcher, queue, executor)
- }
-
- #[test]
- fn a_tick_claims_spawns_and_completes() {
- let (dispatcher, queue, executor) = dispatcher(
- vec![row(1, "repo-a", "fmt"), row(2, "repo-a", "test")],
- DispatcherConfig::default(),
- );
- dispatcher.tick();
- assert_eq!(
- executor.started(),
- vec!["fmt".to_owned(), "test".to_owned()]
- );
- assert_eq!(queue.states(), vec![State::Claimed, State::Claimed]);
- {
- let rows = lock(&queue.rows);
- assert!(
- rows.iter()
- .all(|row| row.claimed_by.as_deref() == Some(dispatcher.claimed_by.as_str()))
- );
- }
-
- executor.release("fmt");
- executor.release("test");
- assert!(eventually(|| queue.all_done()));
- }
-
- #[test]
- fn a_stale_claim_is_requeued_and_redelivered() {
- let mut stale = row(1, "repo-a", "fmt");
- stale.state = State::Claimed;
- stale.claimed_at = Instant::now().checked_sub(Duration::from_secs(600));
- stale.claimed_by = Some("dispatcher-that-died".to_owned());
- let mut fresh = row(2, "repo-b", "test");
- fresh.state = State::Claimed;
- fresh.claimed_at = Some(Instant::now());
- fresh.claimed_by = Some("dispatcher-still-alive".to_owned());
-
- let config = DispatcherConfig {
- claim_timeout: Duration::from_secs(60),
- ..DispatcherConfig::default()
- };
- let (dispatcher, queue, executor) = dispatcher(vec![stale, fresh], config);
- dispatcher.tick();
-
- // The stale claim came back and ran; the fresh claim was left with
- // its (living) claimant, not double-delivered.
- assert_eq!(executor.started(), vec!["fmt".to_owned()]);
- executor.release("fmt");
- assert!(eventually(|| queue.states().first() == Some(&State::Done)));
- assert_eq!(queue.states().get(1), Some(&State::Claimed));
- }
-
- #[test]
- fn the_global_cap_bounds_concurrency() {
- let rows = (1..=5)
- .map(|n| row(n, "repo-a", &format!("effect-{n}")))
- .collect();
- let config = DispatcherConfig {
- global_cap: 2,
- per_repo_cap: 10,
- ..DispatcherConfig::default()
- };
- let (dispatcher, queue, executor) = dispatcher(rows, config);
-
- dispatcher.tick();
- assert_eq!(executor.started().len(), 2);
- // Re-ticking while saturated claims nothing more.
- dispatcher.tick();
- assert_eq!(executor.started().len(), 2);
-
- // A freed slot admits exactly one more on the next drain.
- executor.release("effect-1");
- assert!(eventually(|| lock(&dispatcher.running).global == 1));
- dispatcher.tick();
- assert_eq!(executor.started().len(), 3);
-
- for n in 2..=5 {
- executor.release(&format!("effect-{n}"));
- assert!(eventually(
- || lock(&dispatcher.running).global < dispatcher.config.global_cap
- ));
- dispatcher.tick();
- }
- assert!(eventually(|| queue.all_done()));
- assert_eq!(executor.started().len(), 5);
- }
-
- #[test]
- fn the_per_repo_cap_keeps_a_backlogged_repo_from_starving_others() {
- // repo-a's three jobs are older (lower ids) than repo-b's one; with
- // a per-repo cap of 1, repo-b must still run immediately.
- let rows = vec![
- row(1, "repo-a", "a-1"),
- row(2, "repo-a", "a-2"),
- row(3, "repo-a", "a-3"),
- row(4, "repo-b", "b-1"),
- ];
- let config = DispatcherConfig {
- global_cap: 8,
- per_repo_cap: 1,
- ..DispatcherConfig::default()
- };
- let (dispatcher, queue, executor) = dispatcher(rows, config);
-
- dispatcher.tick();
- assert_eq!(executor.started(), vec!["a-1".to_owned(), "b-1".to_owned()]);
-
- // repo-a proceeds FIFO as its slot frees; repo-b's completion
- // doesn't admit more repo-a work beyond its cap.
- executor.release("a-1");
- assert!(eventually(|| {
- lock(&dispatcher.running).per_repo.get("repo-a").copied() != Some(1)
- }));
- dispatcher.tick();
- assert_eq!(
- executor.started(),
- vec!["a-1".to_owned(), "b-1".to_owned(), "a-2".to_owned()]
- );
-
- executor.release("a-2");
- executor.release("b-1");
- assert!(eventually(|| lock(&dispatcher.running).per_repo.is_empty()));
- dispatcher.tick();
- executor.release("a-3");
- assert!(eventually(|| queue.all_done()));
- }
-
- #[test]
- fn a_malformed_payload_is_completed_without_running() {
- let mut poison = row(1, "repo-a", "unused");
- poison.payload = "not a payload".to_owned();
- let (dispatcher, queue, executor) = dispatcher(vec![poison], DispatcherConfig::default());
- dispatcher.tick();
- assert!(executor.started().is_empty());
- assert_eq!(queue.states(), vec![State::Done]);
- }
-
- #[test]
- fn a_failed_spawn_leaves_the_row_claimed_for_redelivery() {
- /// An executor that refuses every spawn.
- struct DownExecutor;
- impl EffectExecutor for DownExecutor {
- fn spawn(
- &self,
- _effect: &EffectDef,
- _inputs: MaterializedInputs,
- ) -> git_backend::Result<EffectHandle> {
- Err(git_backend::Error::Effect("the sandbox is down".to_owned()))
- }
- fn wait(&self, _handle: &EffectHandle) -> git_backend::Result<EffectStatus> {
- Err(git_backend::Error::Effect("nothing ever spawns".to_owned()))
- }
- }
-
- let queue = Arc::new(FakeQueue::new(vec![row(1, "repo-a", "fmt")]));
- let dispatcher = Dispatcher::new(
- Arc::clone(&queue) as Arc<dyn EffectQueue>,
- Arc::new(DownExecutor),
- DispatcherConfig::default(),
- );
- dispatcher.tick();
- // Claimed, not done: the work never started, so the stale-claim
- // timeout will redeliver it.
- assert_eq!(queue.states(), vec![State::Claimed]);
- }
-}
crates/effect-dispatcher/src/queue.rs
@@ -1,77 +1,0 @@
-//! The dispatcher's queue seam: [`EffectQueue`] abstracts
-//! `git_ents_effect_queue`'s claim/complete/requeue triangle so the
-//! dispatcher loop is tested against an in-memory fake, with
-//! [`refstore_postgres::PostgresRefStore`]'s `dispatcher_*` surface as the
-//! real implementation.
-
-use std::time::Duration;
-
-use git_backend::Result;
-
-/// One claimed queue row: the id [`EffectQueue::complete`] takes back, the
-/// repository it belongs to (per-repo fairness accounting), and the
-/// payload [`crate::job::decode`] reads.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct QueuedJob {
- /// The row's id.
- pub id: i64,
- /// The repository the row was enqueued for.
- pub repo: String,
- /// The enqueued payload.
- pub payload: String,
-}
-
-/// The at-least-once effect queue (`docs/scale-out.adoc`, "RefStore": the
-/// queue table, not the watch channel, carries the guarantee). `claim`
-/// transitions rows `enqueued → claimed` with a claimant and timestamp;
-/// `complete` transitions `claimed → done`; `requeue_stale` returns claims
-/// older than a timeout to `enqueued` — redelivery for a dispatcher that
-/// died with claims outstanding. At-least-once, not exactly-once: a
-/// redelivered row can run its effect twice, and effects are recorded per
-/// commit, so the duplicate re-records the same outcome.
-pub trait EffectQueue: Send + Sync {
- /// Atomically claim up to `limit` of the oldest `enqueued` rows for
- /// `claimed_by`, skipping rows whose repository is in `exclude_repos`
- /// (repositories at their fairness cap).
- fn claim(
- &self,
- claimed_by: &str,
- limit: usize,
- exclude_repos: &[String],
- ) -> Result<Vec<QueuedJob>>;
-
- /// Mark a claimed row done.
- fn complete(&self, id: i64) -> Result<()>;
-
- /// Return every claim older than `older_than` to `enqueued`, returning
- /// how many rows were requeued.
- fn requeue_stale(&self, older_than: Duration) -> Result<u64>;
-}
-
-impl EffectQueue for refstore_postgres::PostgresRefStore {
- fn claim(
- &self,
- claimed_by: &str,
- limit: usize,
- exclude_repos: &[String],
- ) -> Result<Vec<QueuedJob>> {
- let limit = i64::try_from(limit).unwrap_or(i64::MAX);
- Ok(self
- .dispatcher_claim(claimed_by, limit, exclude_repos)?
- .into_iter()
- .map(|row| QueuedJob {
- id: row.id.into(),
- repo: row.repo_id,
- payload: row.payload,
- })
- .collect())
- }
-
- fn complete(&self, id: i64) -> Result<()> {
- self.dispatcher_complete(id.into())
- }
-
- fn requeue_stale(&self, older_than: Duration) -> Result<u64> {
- self.dispatcher_requeue_stale(older_than)
- }
-}
crates/effect-dispatcher/tests/postgres_queue.rs
@@ -1,236 +1,0 @@
-//! The dispatcher's claim/requeue/complete SQL against a real Postgres,
-//! exercised through the [`effect_dispatcher::EffectQueue`] impl for
-//! [`refstore_postgres::PostgresRefStore`]. Gated on a reachable Postgres
-//! exactly like `refstore-postgres`' own suites (whose harness this
-//! duplicates, as `odb_ws5_conformance` already does):
-//!
-//! 1. `GIT_ENTS_TEST_POSTGRES_URL`, if set — an already-running Postgres.
-//! 2. A throwaway `docker run` Postgres container, if docker is available.
-//! 3. Otherwise: a visible skip.
-//!
-//! One caveat the SQL makes unavoidable: `dispatcher_*` queries span every
-//! `repo_id` by design, so against a *shared* external database
-//! (`GIT_ENTS_TEST_POSTGRES_URL`) this test can claim rows other suites
-//! enqueued. The docker path — one container per test — is fully isolated;
-//! assertions below filter to this test's own repo ids rather than assert
-//! global counts, so an externally shared database perturbs nothing here.
-
-#![allow(
- clippy::unwrap_used,
- clippy::expect_used,
- reason = "test harness and assertions, not application code"
-)]
-
-use std::process::{Command, Stdio};
-use std::time::Duration;
-
-use effect_dispatcher::EffectQueue as _;
-use refstore_postgres::PostgresRefStore;
-
-/// A reachable test Postgres: either an externally supplied instance or a
-/// throwaway docker container this harness starts and stops.
-enum TestPostgres {
- External(String),
- Docker { container_id: String, url: String },
-}
-
-impl TestPostgres {
- fn url(&self) -> &str {
- match self {
- Self::External(url) | Self::Docker { url, .. } => url,
- }
- }
-}
-
-impl Drop for TestPostgres {
- fn drop(&mut self) {
- if let Self::Docker { container_id, .. } = self {
- let _ignored = Command::new("docker")
- .args(["rm", "-f", container_id])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- }
- }
-}
-
-/// Obtain a test Postgres per the priority order in the module doc, or
-/// `None` if neither an external URL nor docker is available.
-fn test_postgres() -> Option<TestPostgres> {
- if let Ok(url) = std::env::var("GIT_ENTS_TEST_POSTGRES_URL") {
- return Some(TestPostgres::External(url));
- }
- if !docker_available() {
- return None;
- }
- start_docker_postgres()
-}
-
-fn docker_available() -> bool {
- Command::new("docker")
- .arg("version")
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map(|status| status.success())
- .unwrap_or(false)
-}
-
-fn start_docker_postgres() -> Option<TestPostgres> {
- let output = Command::new("docker")
- .args([
- "run",
- "-d",
- "--rm",
- "-e",
- "POSTGRES_PASSWORD=postgres",
- "-p",
- "127.0.0.1::5432",
- "postgres:16-alpine",
- ])
- .output()
- .ok()?;
- if !output.status.success() {
- eprintln!(
- "effect-dispatcher postgres_queue: docker run failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- return None;
- }
- let container_id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
-
- for _ in 0..120 {
- let ready = Command::new("docker")
- .args(["exec", &container_id, "pg_isready", "-U", "postgres"])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map(|status| status.success())
- .unwrap_or(false);
- if ready {
- break;
- }
- std::thread::sleep(Duration::from_millis(250));
- }
-
- let port_output = Command::new("docker")
- .args(["port", &container_id, "5432"])
- .output()
- .ok()?;
- let mapping = String::from_utf8_lossy(&port_output.stdout);
- let port = mapping
- .lines()
- .next()?
- .rsplit(':')
- .next()?
- .trim()
- .to_owned();
-
- let url = format!("host=127.0.0.1 port={port} user=postgres password=postgres dbname=postgres");
-
- // `pg_isready` above checks the container's internal socket, which can
- // report ready before the postgres entrypoint's post-initdb restart
- // finishes — a raw TCP connect can succeed against that transient
- // listener too. Only a real protocol-level connection confirms the
- // final server is actually up.
- if !wait_for_postgres_ready(&url) {
- eprintln!("effect-dispatcher postgres_queue: postgres never became reachable");
- return None;
- }
-
- Some(TestPostgres::Docker { url, container_id })
-}
-
-fn wait_for_postgres_ready(url: &str) -> bool {
- for _ in 0..40 {
- if PostgresRefStore::connect(url, "readiness-probe").is_ok() {
- return true;
- }
- std::thread::sleep(Duration::from_millis(250));
- }
- false
-}
-
-#[test]
-fn dispatcher_sql_claims_across_repos_requeues_stale_and_completes() {
- let Some(pg) = test_postgres() else {
- eprintln!(
- "skipping dispatcher_sql_claims_across_repos_requeues_stale_and_completes: \
- set GIT_ENTS_TEST_POSTGRES_URL, or make docker available"
- );
- return;
- };
- let repo_a = format!("dispatch-a-{}", uuid::Uuid::new_v4());
- let repo_b = format!("dispatch-b-{}", uuid::Uuid::new_v4());
- let store_a = PostgresRefStore::connect(pg.url(), repo_a.clone()).expect("connect a");
- let store_b = PostgresRefStore::connect(pg.url(), repo_b.clone()).expect("connect b");
- let a_id: i64 = store_a
- .enqueue_effect("payload-a")
- .expect("enqueue a")
- .into();
- let b_id: i64 = store_b
- .enqueue_effect("payload-b")
- .expect("enqueue b")
- .into();
- let mine = |id: i64, repo: &str| repo == repo_a && id == a_id || repo == repo_b && id == b_id;
-
- // The dispatcher claim spans repos: one query sees both stores' rows,
- // each attributed to its repo.
- let claimed = store_a.claim("dispatcher-1", 100, &[]).expect("claim");
- let claimed: Vec<_> = claimed
- .into_iter()
- .filter(|job| mine(job.id, &job.repo))
- .collect();
- assert_eq!(claimed.len(), 2);
- assert!(
- claimed
- .iter()
- .any(|job| job.id == a_id && job.repo == repo_a && job.payload == "payload-a")
- );
- assert!(
- claimed
- .iter()
- .any(|job| job.id == b_id && job.repo == repo_b && job.payload == "payload-b")
- );
-
- // A claimed row is not claimable again while its claim is fresh...
- let reclaimed = store_a
- .claim("dispatcher-2", 100, &[])
- .expect("claim while claimed");
- assert!(reclaimed.iter().all(|job| !mine(job.id, &job.repo)));
-
- // ...but a zero timeout makes every claim stale: both rows come back.
- let requeued = store_a
- .requeue_stale(Duration::ZERO)
- .expect("requeue stale");
- assert!(requeued >= 2);
-
- // The per-repo exclusion (a repo at its fairness cap) skips that
- // repo's rows and still claims the rest.
- let claimed = store_a
- .claim("dispatcher-3", 100, std::slice::from_ref(&repo_b))
- .expect("claim excluding b");
- let ids: Vec<i64> = claimed
- .iter()
- .filter(|job| mine(job.id, &job.repo))
- .map(|job| job.id)
- .collect();
- assert_eq!(ids, vec![a_id]);
-
- // Done is terminal: a completed row never comes back, even through a
- // zero-timeout requeue.
- store_a.complete(a_id).expect("complete a");
- let _requeued = store_a
- .requeue_stale(Duration::ZERO)
- .expect("requeue stale again");
- let claimed = store_a
- .claim("dispatcher-4", 100, &[])
- .expect("claim after complete");
- let ids: Vec<i64> = claimed
- .iter()
- .filter(|job| mine(job.id, &job.repo))
- .map(|job| job.id)
- .collect();
- assert_eq!(ids, vec![b_id]);
- store_b.complete(b_id).expect("complete b");
-}
crates/exec-sprites/Cargo.toml
@@ -1,15 +1,0 @@
-[package]
-name = "exec-sprites"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-git-backend = { workspace = true }
-
-[dev-dependencies]
-gix-hash = { workspace = true }
-
-[lints]
-workspace = true
crates/exec-sprites/src/fly.rs
@@ -1,257 +1,0 @@
-//! The real [`SpriteLauncher`]: shell out to the `fly` (flyctl) CLI's
-//! `machine` commands — the same pattern as `git-effect`'s `docker` and
-//! `sprite` backends, and deliberately not an HTTP client against the
-//! Machines REST API (dependency policy: no new external dependencies).
-//!
-//! The `sprite` CLI the checks engine already drives was considered and
-//! passed over here: it manages one persistent sandbox per repository and
-//! has no image flag, while `exec-sprites`' whole point is one throwaway
-//! machine per effect booted from a WS8-baked image. flyctl's `machine
-//! run` expresses exactly that.
-//!
-//! Honesty about coverage: argv assembly ([`run_args`]) and output parsing
-//! ([`parse_machine_id`], [`machine_settled`]) are pure and unit-tested;
-//! *validating them against a live flyctl* is deploy-only work — flyctl's
-//! human-oriented output is unversioned, and nothing in this repository
-//! can pin it. Each parsing site carries the caveat.
-
-use std::process::Command;
-use std::time::{Duration, Instant};
-
-use git_backend::{Error, Result};
-
-use crate::{MachineSpec, SpriteLauncher};
-
-/// How long [`FlyLauncher::wait`] polls a machine before giving up —
-/// matches the effect engine's own 30-minute per-effect timeout, so a
-/// wedged machine is abandoned on the same clock as a wedged local run.
-const WAIT_TIMEOUT: Duration = Duration::from_secs(30 * 60);
-
-/// How often [`FlyLauncher::wait`] polls `fly machine status`.
-const POLL: Duration = Duration::from_secs(2);
-
-/// [`SpriteLauncher`] over the `fly` CLI: `fly machine run --rm --detach`
-/// to create, `fly machine status` polling to wait. Authentication is
-/// flyctl's own (`FLY_API_TOKEN`, or its config file) — this launcher
-/// passes nothing secret on any command line.
-pub struct FlyLauncher {
- bin: String,
- app: String,
- poll: Duration,
- wait_timeout: Duration,
-}
-
-impl FlyLauncher {
- /// A launcher creating machines in the Fly app `app` via the `fly`
- /// binary on `PATH`.
- #[must_use]
- pub fn new(app: impl Into<String>) -> Self {
- Self {
- bin: "fly".to_owned(),
- app: app.into(),
- poll: POLL,
- wait_timeout: WAIT_TIMEOUT,
- }
- }
-}
-
-/// `fly machine run`'s argv for `spec` — pure, so the exact invocation is
-/// unit-tested without flyctl (the same pattern as
-/// `git_effect::docker::run_args`). Flags precede the positional image and
-/// command so a command word can never be mistaken for a flag; `--rm`
-/// reaps the machine on exit, `--detach` returns once it is created (the
-/// executor's `spawn` must not block for completion).
-#[must_use]
-pub fn run_args(app: &str, spec: &MachineSpec) -> Vec<String> {
- let mut args = vec![
- "machine".to_owned(),
- "run".to_owned(),
- "--app".to_owned(),
- app.to_owned(),
- "--name".to_owned(),
- spec.name.clone(),
- "--rm".to_owned(),
- "--detach".to_owned(),
- ];
- for (key, value) in &spec.env {
- args.push("--env".to_owned());
- args.push(format!("{key}={value}"));
- }
- args.push(spec.image.clone());
- args.push("sh".to_owned());
- args.push("-c".to_owned());
- args.push(spec.command.clone());
- args
-}
-
-/// The machine id out of `fly machine run --detach`'s output: the value of
-/// its `Machine ID: <id>` line, or, failing that, the first token shaped
-/// like a machine id (14 lowercase hex characters). Deploy-only caveat:
-/// this matches the output shape current flyctl releases print; a live
-/// `fly machine run` is the only authority on whether it still holds.
-#[must_use]
-pub fn parse_machine_id(output: &str) -> Option<String> {
- for line in output.lines() {
- if let Some(rest) = line.trim().strip_prefix("Machine ID:") {
- let id = rest.trim();
- if !id.is_empty() {
- return Some(id.to_owned());
- }
- }
- }
- output
- .split_whitespace()
- .find(|token| {
- token.len() == 14
- && token
- .chars()
- .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase() && c.is_ascii_hexdigit())
- })
- .map(str::to_owned)
-}
-
-/// Whether a `fly machine status` output describes a settled machine
-/// (stopped or destroyed). Same deploy-only caveat as
-/// [`parse_machine_id`].
-#[must_use]
-pub fn machine_settled(status_output: &str) -> bool {
- let lowered = status_output.to_lowercase();
- lowered.contains("stopped") || lowered.contains("destroyed")
-}
-
-impl SpriteLauncher for FlyLauncher {
- fn launch(&self, spec: &MachineSpec) -> Result<String> {
- let output = Command::new(&self.bin)
- .args(run_args(&self.app, spec))
- .output()
- .map_err(|e| {
- Error::Effect(format!(
- "could not run the fly CLI (is flyctl installed?): {e}"
- ))
- })?;
- if !output.status.success() {
- return Err(Error::Effect(format!(
- "fly machine run failed: {}",
- String::from_utf8_lossy(&output.stderr).trim()
- )));
- }
- let stdout = String::from_utf8_lossy(&output.stdout);
- parse_machine_id(&stdout).ok_or_else(|| {
- Error::Effect(
- "fly machine run succeeded but no machine id was found in its output".to_owned(),
- )
- })
- }
-
- fn wait(&self, machine: &str) -> Result<()> {
- let deadline = Instant::now()
- .checked_add(self.wait_timeout)
- .ok_or_else(|| Error::Effect("wait timeout overflowed the clock".to_owned()))?;
- loop {
- let output = Command::new(&self.bin)
- .args(["machine", "status", machine, "--app", &self.app])
- .output()
- .map_err(|e| {
- Error::Effect(format!(
- "could not run the fly CLI (is flyctl installed?): {e}"
- ))
- })?;
- let text = format!(
- "{}\n{}",
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- );
- if !output.status.success() {
- // `--rm` reaps the machine on exit, so "not found" after a
- // successful launch means it ran and was already destroyed:
- // settled. (Deploy-only caveat as above.)
- let lowered = text.to_lowercase();
- if lowered.contains("not found") || lowered.contains("could not find") {
- return Ok(());
- }
- return Err(Error::Effect(format!(
- "fly machine status failed for {machine}: {}",
- text.trim()
- )));
- }
- if machine_settled(&text) {
- return Ok(());
- }
- if Instant::now() >= deadline {
- return Err(Error::Effect(format!(
- "machine {machine} did not settle within {:?}",
- self.wait_timeout
- )));
- }
- std::thread::sleep(self.poll);
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "unit test")]
-
- use std::collections::BTreeMap;
-
- use super::*;
-
- fn spec() -> MachineSpec {
- let mut env = BTreeMap::new();
- env.insert("GIT_ENTS_EFFECT".to_owned(), "test".to_owned());
- MachineSpec {
- name: "effect-test-aaaaaaaaaaaa".to_owned(),
- image: "registry.fly.io/git-ents-effects:baked".to_owned(),
- env,
- command: "cargo test".to_owned(),
- }
- }
-
- #[test]
- fn run_args_put_flags_before_the_positional_image_and_command() {
- let args = run_args("git-ents-effects", &spec());
- assert_eq!(
- args,
- vec![
- "machine",
- "run",
- "--app",
- "git-ents-effects",
- "--name",
- "effect-test-aaaaaaaaaaaa",
- "--rm",
- "--detach",
- "--env",
- "GIT_ENTS_EFFECT=test",
- "registry.fly.io/git-ents-effects:baked",
- "sh",
- "-c",
- "cargo test",
- ]
- );
- }
-
- #[test]
- fn parse_machine_id_prefers_the_labeled_line() {
- let output = "Success! A Machine has been successfully launched\n\
- Machine ID: 148ed599c14189\n\
- Instance ID: 01HXYZ\n";
- assert_eq!(parse_machine_id(output), Some("148ed599c14189".to_owned()));
- }
-
- #[test]
- fn parse_machine_id_falls_back_to_an_id_shaped_token() {
- assert_eq!(
- parse_machine_id("launched 148ed599c14189 in yyz"),
- Some("148ed599c14189".to_owned())
- );
- assert_eq!(parse_machine_id("no ids here"), None);
- }
-
- #[test]
- fn machine_settled_matches_stopped_and_destroyed() {
- assert!(machine_settled("State: stopped"));
- assert!(machine_settled("machine was destroyed"));
- assert!(!machine_settled("State: started"));
- }
-}
crates/exec-sprites/src/lib.rs
@@ -1,383 +1,0 @@
-//! `exec-sprites`: [`git_backend::EffectExecutor`] over Fly Machines
-//! ("Sprites") — the hosted row of `docs/scale-out.adoc`'s "EffectExecutor"
-//! table (WS7: the dispatcher drains the effect queue and creates Sprites
-//! through this crate).
-//!
-//! One machine per effect, created through a [`SpriteLauncher`].
-//! [`FlyLauncher`] is the real one: it shells out to the `fly` (flyctl)
-//! CLI, the same way the workspace's other sandbox backends shell out to
-//! `docker` and `sprite` — no HTTP client dependency, per the dependency
-//! policy.
-//!
-//! # What comes back, and how
-//!
-//! Nothing returns through the machine. Results and cache entries return
-//! via *attested push* signed with the worker member key the machine is
-//! provisioned with (`docs/scale-out.adoc`, WS7 and "Attested push": key
-//! availability in Sprites is exactly the enrollment cost uniform-strong
-//! attestation already pays). [`WorkerKey`] models that provisioning: the
-//! machine spec carries only the *name* of the secret-provisioned
-//! environment variable holding the key material — the material itself is
-//! set out-of-band (`fly secrets set`, or machine secrets at deploy time)
-//! and never travels through a machine-create argument, where
-//! `fly machine status` would echo it. The launcher consequently observes
-//! only machine lifecycle; [`git_backend::EffectExecutor::wait`] here
-//! settles [`EffectStatus::SettledRemotely`], and the recorded run refs are
-//! the outcome's source of truth.
-//!
-//! # The image (WS8)
-//!
-//! [`SpriteConfig::image`] (or an effect's own `image` override) is
-//! expected to carry a baked toolchain object store (WS8, "Hydration and
-//! toolchains"): materialization inside the machine stays the one code
-//! path of correctness rule 6, the baked store merely being the tier that
-//! answers `read` on a hit, with a miss falling through to fetch. Nothing
-//! here bakes or verifies images; this crate only names what to boot.
-//!
-//! # What needs a real deployment
-//!
-//! Everything assembled here — machine specs, argv, env plumbing — is pure
-//! and unit-tested against a fake launcher. What is *not* claimable
-//! in-repo: flyctl's output shapes ([`fly::parse_machine_id`], the status
-//! text [`FlyLauncher`] polls) and the end-to-end attested results push,
-//! which need a deployed Fly app and a provisioned worker member key to
-//! exercise. Those spots carry their own deploy-only notes.
-
-mod fly;
-
-pub use fly::FlyLauncher;
-
-use std::collections::BTreeMap;
-
-use git_backend::{
- EffectDef, EffectExecutor, EffectHandle, EffectStatus, Error, MaterializedInputs, Result,
-};
-
-/// Env var carrying the effect's name into the machine.
-pub const EFFECT_ENV: &str = "GIT_ENTS_EFFECT";
-
-/// Env var carrying the tree the effect runs against (full hex OID).
-pub const TREE_ENV: &str = "GIT_ENTS_TREE";
-
-/// Env var carrying the remote the in-machine runner pushes its results
-/// and cache refs to (the attested push's destination).
-pub const RESULTS_REMOTE_ENV: &str = "GIT_ENTS_RESULTS_REMOTE";
-
-/// Env var carrying the worker member name whose key signs the results
-/// push.
-pub const WORKER_MEMBER_ENV: &str = "GIT_ENTS_WORKER_MEMBER";
-
-/// Env var carrying the *name* of the secret-provisioned env var that
-/// holds the worker member's private key material (see [`WorkerKey`]).
-pub const WORKER_KEY_ENV: &str = "GIT_ENTS_WORKER_KEY_ENV";
-
-/// Env var carrying the colon-joined `PATH` entries of the activated
-/// toolchains, in toolchain-name order.
-pub const TOOLCHAIN_PATH_ENV: &str = "GIT_ENTS_TOOLCHAIN_PATH";
-
-/// Env var carrying the effect's cache name, when it declares one.
-pub const CACHE_ENV: &str = "GIT_ENTS_CACHE";
-
-/// Everything a launcher needs to create one machine: which image to boot,
-/// what to run in it, and the environment the in-machine runner reads its
-/// work order from.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct MachineSpec {
- /// The machine's name (derived from the effect and its tree).
- pub name: String,
- /// The image to boot — expected to carry the baked toolchain object
- /// store (WS8).
- pub image: String,
- /// The environment the runner reads its work order from. Never carries
- /// key material, only the name of the secret that does.
- pub env: BTreeMap<String, String>,
- /// The effect's shell command, run under `sh -c`.
- pub command: String,
-}
-
-/// How a Sprite actually gets created and reaped. [`FlyLauncher`] shells
-/// out to flyctl; tests substitute a fake to assert the [`MachineSpec`]
-/// without any Fly dependency.
-pub trait SpriteLauncher: Send + Sync {
- /// Create and start a machine per `spec`, returning its backend id.
- /// Must not block for the effect's completion.
- fn launch(&self, spec: &MachineSpec) -> Result<String>;
-
- /// Block until machine `machine` has settled (stopped, or already
- /// reaped).
- fn wait(&self, machine: &str) -> Result<()>;
-}
-
-/// The worker member identity a machine pushes results back as: an
-/// enrolled member (`refs/meta/members/*`) whose key material is
-/// provisioned to the machine as a secret env var named
-/// [`WorkerKey::key_env`]. Modeled as configuration because the material
-/// itself must stay out of machine-create arguments; provisioning the
-/// secret is a deploy step this crate cannot perform.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct WorkerKey {
- /// The enrolled worker member's name.
- pub member: String,
- /// The name of the env var (a Fly secret) holding the member's private
- /// key material inside the machine.
- pub key_env: String,
-}
-
-/// The executor's fixed configuration: the default image, where results
-/// push back to, and the worker member identity that signs the push.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SpriteConfig {
- /// The default image to boot when an effect names none — expected to
- /// carry the baked toolchain object store (WS8).
- pub image: String,
- /// The remote the in-machine runner pushes results and cache refs to.
- pub results_remote: String,
- /// The worker member identity signing that push.
- pub worker_key: WorkerKey,
-}
-
-/// [`EffectExecutor`] creating one machine per spawned effect through a
-/// [`SpriteLauncher`].
-pub struct SpriteExecutor<L> {
- launcher: L,
- config: SpriteConfig,
-}
-
-impl<L: SpriteLauncher> SpriteExecutor<L> {
- /// An executor creating machines through `launcher` per `config`.
- #[must_use]
- pub fn new(launcher: L, config: SpriteConfig) -> Self {
- Self { launcher, config }
- }
-}
-
-/// Assemble the [`MachineSpec`] for one effect — pure, so exactly what a
-/// machine is created with (image selection, env plumbing, key-material
-/// indirection) is unit-tested without a launcher.
-///
-/// # Errors
-///
-/// Returns [`Error::Effect`] for a composite effect (no command): the
-/// engine derives its outcome from its dependencies instead of spawning it.
-pub fn machine_spec(
- config: &SpriteConfig,
- effect: &EffectDef,
- inputs: &MaterializedInputs,
-) -> Result<MachineSpec> {
- let Some(command) = effect.command.clone() else {
- return Err(Error::Effect(format!(
- "effect {} is composite (no command); its outcome derives from its \
- dependencies instead of a spawn",
- effect.name
- )));
- };
-
- let tree = inputs.tree.to_string();
- let mut env = BTreeMap::new();
- env.insert(EFFECT_ENV.to_owned(), effect.name.clone());
- env.insert(TREE_ENV.to_owned(), tree.clone());
- env.insert(RESULTS_REMOTE_ENV.to_owned(), config.results_remote.clone());
- env.insert(
- WORKER_MEMBER_ENV.to_owned(),
- config.worker_key.member.clone(),
- );
- env.insert(WORKER_KEY_ENV.to_owned(), config.worker_key.key_env.clone());
- if !inputs.toolchain_paths.is_empty() {
- let path = inputs
- .toolchain_paths
- .values()
- .map(String::as_str)
- .collect::<Vec<_>>()
- .join(":");
- env.insert(TOOLCHAIN_PATH_ENV.to_owned(), path);
- }
- if let Some(cache) = &inputs.cache {
- env.insert(CACHE_ENV.to_owned(), cache.clone());
- }
-
- Ok(MachineSpec {
- name: machine_name(&effect.name, &tree),
- image: effect.image.clone().unwrap_or_else(|| config.image.clone()),
- env,
- command,
- })
-}
-
-/// A machine name for `effect` at `tree_hex`, kept to the `[a-z0-9-]` a
-/// machine name allows (mirroring `git_effect::engine::sprite_name`'s
-/// sanitization): `effect-<name>-<tree prefix>`.
-fn machine_name(effect: &str, tree_hex: &str) -> String {
- let sanitized: String = effect
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() {
- c.to_ascii_lowercase()
- } else {
- '-'
- }
- })
- .collect();
- let trimmed = sanitized.trim_matches('-');
- let name = if trimmed.is_empty() {
- "effect"
- } else {
- trimmed
- };
- let short = tree_hex.get(..12).unwrap_or(tree_hex);
- format!("effect-{name}-{short}")
-}
-
-impl<L: SpriteLauncher> EffectExecutor for SpriteExecutor<L> {
- fn spawn(&self, effect: &EffectDef, inputs: MaterializedInputs) -> Result<EffectHandle> {
- let spec = machine_spec(&self.config, effect, &inputs)?;
- let id = self.launcher.launch(&spec)?;
- Ok(EffectHandle { id })
- }
-
- fn wait(&self, handle: &EffectHandle) -> Result<EffectStatus> {
- self.launcher.wait(&handle.id)?;
- // The machine's termination is all this executor can observe; the
- // outcome itself returns via the attested results push (crate
- // docs). Exit-code sniffing through flyctl is deliberately not
- // attempted — it would duplicate, and could contradict, the
- // recorded run refs.
- Ok(EffectStatus::SettledRemotely)
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::unwrap_in_result,
- clippy::indexing_slicing,
- clippy::assertions_on_result_states,
- reason = "unit test"
- )]
-
- use std::sync::Mutex;
-
- use super::*;
-
- fn tree() -> gix_hash::ObjectId {
- gix_hash::ObjectId::from_hex(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap()
- }
-
- fn config() -> SpriteConfig {
- SpriteConfig {
- image: "registry.fly.io/git-ents-effects:baked".to_owned(),
- results_remote: "https://ents.example/repo.git".to_owned(),
- worker_key: WorkerKey {
- member: "worker-1".to_owned(),
- key_env: "WORKER_SSH_KEY".to_owned(),
- },
- }
- }
-
- fn effect(command: Option<&str>, image: Option<&str>) -> EffectDef {
- EffectDef {
- name: "Build & Test".to_owned(),
- command: command.map(str::to_owned),
- image: image.map(str::to_owned),
- }
- }
-
- fn inputs() -> MaterializedInputs {
- let mut toolchain_paths = BTreeMap::new();
- toolchain_paths.insert("rust".to_owned(), "/toolchains/aaa/bin".to_owned());
- toolchain_paths.insert("zig".to_owned(), "/toolchains/bbb/bin".to_owned());
- MaterializedInputs {
- tree: tree(),
- toolchain_paths,
- cache: Some("sccache".to_owned()),
- }
- }
-
- /// Captures every launched spec; `wait` records the machine id it was
- /// asked about.
- struct FakeLauncher {
- launched: Mutex<Vec<MachineSpec>>,
- waited: Mutex<Vec<String>>,
- }
-
- impl FakeLauncher {
- fn new() -> Self {
- Self {
- launched: Mutex::new(Vec::new()),
- waited: Mutex::new(Vec::new()),
- }
- }
- }
-
- impl SpriteLauncher for FakeLauncher {
- fn launch(&self, spec: &MachineSpec) -> Result<String> {
- self.launched.lock().unwrap().push(spec.clone());
- Ok(format!("machine-{}", self.launched.lock().unwrap().len()))
- }
-
- fn wait(&self, machine: &str) -> Result<()> {
- self.waited.lock().unwrap().push(machine.to_owned());
- Ok(())
- }
- }
-
- #[test]
- fn machine_spec_plumbs_image_env_and_key_material_indirection() {
- let spec = machine_spec(&config(), &effect(Some("cargo test"), None), &inputs()).unwrap();
-
- assert_eq!(spec.image, "registry.fly.io/git-ents-effects:baked");
- assert_eq!(spec.command, "cargo test");
- assert_eq!(spec.name, "effect-build---test-aaaaaaaaaaaa");
- assert_eq!(spec.env[EFFECT_ENV], "Build & Test");
- assert_eq!(spec.env[TREE_ENV], tree().to_string());
- assert_eq!(
- spec.env[RESULTS_REMOTE_ENV],
- "https://ents.example/repo.git"
- );
- assert_eq!(spec.env[WORKER_MEMBER_ENV], "worker-1");
- // Only the *name* of the secret-provisioned variable travels in
- // the spec — never key bytes.
- assert_eq!(spec.env[WORKER_KEY_ENV], "WORKER_SSH_KEY");
- assert_eq!(
- spec.env[TOOLCHAIN_PATH_ENV],
- "/toolchains/aaa/bin:/toolchains/bbb/bin"
- );
- assert_eq!(spec.env[CACHE_ENV], "sccache");
- }
-
- #[test]
- fn an_effects_own_image_overrides_the_default() {
- let spec = machine_spec(
- &config(),
- &effect(Some("true"), Some("registry.fly.io/custom:1")),
- &inputs(),
- )
- .unwrap();
- assert_eq!(spec.image, "registry.fly.io/custom:1");
- }
-
- #[test]
- fn a_composite_effect_is_never_launched() {
- assert!(machine_spec(&config(), &effect(None, None), &inputs()).is_err());
- }
-
- #[test]
- fn spawn_launches_and_wait_settles_remotely() {
- let executor = SpriteExecutor::new(FakeLauncher::new(), config());
- let handle = executor
- .spawn(&effect(Some("cargo test"), None), inputs())
- .unwrap();
- assert_eq!(handle.id, "machine-1");
- assert_eq!(
- executor.wait(&handle).unwrap(),
- EffectStatus::SettledRemotely
- );
- assert_eq!(
- *executor.launcher.waited.lock().unwrap(),
- vec!["machine-1".to_owned()]
- );
- let launched = executor.launcher.launched.lock().unwrap();
- assert_eq!(launched.len(), 1);
- assert_eq!(launched.first().unwrap().command, "cargo test");
- }
-}
crates/git-anchor/Cargo.toml
@@ -1,17 +1,0 @@
-[package]
-name = "git-anchor"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-facet = { workspace = true }
-gix = { workspace = true }
-thiserror = { workspace = true }
-
-[dev-dependencies]
-git-store = { workspace = true, features = ["test-support"] }
-
-[lints]
-workspace = true
crates/git-anchor/src/lib.rs
@@ -1,973 +1,0 @@
-//! Blob-anchored positions and their forward projection onto later commits.
-//!
-//! An [`Anchor`] records exactly where in a repository something (a comment, a
-//! review note) was attached: the commit it was written against, the path and
-//! blob at that commit, and an optional line range. The anchored text is never
-//! stored — the blob is content-addressed, so [`snippet`] derives it exactly
-//! at read time. The anchor is authoritative at creation and never mutated.
-//!
-//! `commit` is recorded on a best-effort basis: nothing pins it against
-//! garbage collection, so it may no longer exist by the time the anchor is
-//! read back. [`project`] answers, at read time, where the anchor's position
-//! sits on any *other* commit — following renames through git's rewrite
-//! tracking and shifting line ranges through the blob's diff hunks, the way
-//! git itself re-derives positions when replaying diffs on rebase — but it
-//! needs `commit` to still exist to do so. [`context`] captures a small,
-//! independently-retainable window of surrounding lines at capture time;
-//! [`project_from_context`] fuzzy-matches that window against a target
-//! commit's version of the same path when `commit` is gone, giving projection
-//! a fallback that survives the anchor commit's own collection.
-//!
-//! Projection is a two-point tree diff, not a history walk: it compares the
-//! anchor commit's tree directly against the target commit's tree, so it works
-//! whether the target is a descendant, an ancestor, or an unrelated commit.
-//! Blame answers the backwards question (which commit introduced a line); the
-//! forward question asked here needs only the diff.
-
-use std::path::Path;
-
-use facet::Facet;
-use gix::ObjectId;
-use gix::bstr::ByteSlice as _;
-use gix::diff::blob::{Algorithm, Diff, InternedInput};
-use gix::diff::tree_with_rewrites::Change;
-
-/// A content-addressed object id, stored on disk as its 40-character hex text
-/// (identical to a bare `String` field, via `facet_git_tree`'s
-/// transparent-newtype support) and used everywhere else as gitoxide's own
-/// [`ObjectId`] — so an [`Anchor`] never carries a hex string a caller could
-/// mistake for an arbitrary revision.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-#[facet(transparent)]
-pub struct Oid(String);
-
-impl std::fmt::Display for Oid {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-impl From<ObjectId> for Oid {
- fn from(id: ObjectId) -> Self {
- Self(id.to_string())
- }
-}
-
-impl From<&str> for Oid {
- fn from(hex: &str) -> Self {
- Self(hex.to_owned())
- }
-}
-
-impl TryFrom<&Oid> for ObjectId {
- type Error = gix::hash::decode::Error;
-
- fn try_from(oid: &Oid) -> Result<Self, Self::Error> {
- ObjectId::from_hex(oid.0.as_bytes())
- }
-}
-
-/// A failure opening the repository or resolving the objects an anchor names.
-#[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>),
- /// A revision or object id could not be resolved to the object it names.
- #[error("could not resolve {0:?}")]
- Resolve(String),
- /// A git object could not be read.
- #[error("git object operation failed: {0}")]
- Object(String),
- /// The tree diff between the anchor commit and the target commit failed.
- #[error("tree diff failed: {0}")]
- Diff(String),
- /// The anchor names a path that is not a file in its commit.
- #[error("no file at {path:?} in {commit}")]
- MissingPath {
- /// The commit the path was looked up in.
- commit: ObjectId,
- /// The path that is not a file there.
- path: String,
- },
- /// The line range does not fit the file it is anchored to.
- #[error("lines {start}..={end} do not fit {path:?} ({len} lines)")]
- LinesOutOfRange {
- /// The file the range was checked against.
- path: String,
- /// The 1-based first line of the range.
- start: u64,
- /// The 1-based last line of the range.
- end: u64,
- /// How many lines the file actually has.
- len: u64,
- },
- /// [`project`]'s anchor commit is no longer present in the repository
- /// (garbage collected) — the trigger for [`project_from_context`]'s
- /// fuzzy-matching fallback, which needs no commit at all.
- #[error("the anchor commit {0} no longer exists")]
- AnchorCommitMissing(ObjectId),
-}
-
-/// A 1-based inclusive range of lines within an anchored file.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
-pub struct LineRange {
- /// The first line of the range, 1-based.
- pub start: u64,
- /// The last line of the range, inclusive.
- pub end: u64,
-}
-
-/// Where in the repository something was attached — authoritative at creation
-/// and never mutated afterwards. Projection onto other commits is always a
-/// read-time view derived from this record.
-///
-/// ## Requirements
-///
-/// @relation(comments.anchor)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Anchor {
- /// The commit the anchor was created against, recorded on a best-effort
- /// basis: nothing keeps it reachable, so it may be gone (garbage
- /// collected) by the time the anchor is read back. [`project`] needs it
- /// to still exist; [`project_from_context`] does not.
- pub commit: Oid,
- /// The repository-relative path of the anchored file at that commit.
- pub path: String,
- /// The object id of the anchored file's blob — an integrity check and the
- /// fast path for "has this file changed at all".
- pub blob: Oid,
- /// The anchored lines, or `None` for a whole-file anchor.
- pub lines: Option<LineRange>,
-}
-
-/// Where an [`Anchor`] sits on a target commit, as computed by [`project`].
-///
-/// ## Requirements
-///
-/// @relation(comments.projection)
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Projection {
- /// The target tree holds the anchor's exact blob at its exact path; the
- /// anchor applies unchanged.
- Current,
- /// The file moved and/or its content shifted, but the anchored region
- /// itself is intact — the anchor now applies at `path` and `lines`.
- Relocated {
- /// The anchored file's path in the target tree.
- path: String,
- /// The anchored lines mapped into the target blob, or `None` for a
- /// whole-file anchor.
- lines: Option<LineRange>,
- },
- /// The file survives at `path` but the anchored lines were edited (or the
- /// entry is no longer a regular file); the anchor no longer maps cleanly.
- Outdated {
- /// The anchored file's path in the target tree.
- path: String,
- },
- /// The anchored file does not exist in the target tree.
- FileDeleted,
-}
-
-/// Build the [`Anchor`] for `path` (and optionally `lines`) as it exists at
-/// `revision` in `repo`, resolving the revision to a full commit id and
-/// recording the file's blob id. Fails when the path is not a file at that
-/// commit or the range does not fit it.
-///
-/// ## Requirements
-///
-/// @relation(comments.anchor)
-pub fn capture(
- repo: &Path,
- revision: &str,
- path: &str,
- lines: Option<LineRange>,
-) -> Result<Anchor, Error> {
- let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
- let commit = resolve_commit(&repo, revision)?;
- let commit_id = commit.id().detach();
- let tree = commit
- .tree()
- .map_err(|error| Error::Object(error.to_string()))?;
- let entry = tree
- .lookup_entry_by_path(path)
- .map_err(|error| Error::Object(error.to_string()))?
- .filter(|entry| entry.mode().is_blob())
- .ok_or_else(|| Error::MissingPath {
- commit: commit_id,
- path: path.to_owned(),
- })?;
- let blob = entry.object_id();
- if let Some(range) = lines {
- let data = read_blob(&repo, blob)?;
- lines_of(&data, path, range)?;
- }
- Ok(Anchor {
- commit: commit_id.into(),
- path: path.to_owned(),
- blob: blob.into(),
- lines,
- })
-}
-
-/// The exact text of `anchor`'s lines — the whole file for a whole-file
-/// anchor — derived at read time from the content-addressed blob the anchor
-/// names, so it can never disagree with what was anchored.
-///
-/// ## Requirements
-///
-/// @relation(comments.anchor)
-pub fn snippet(repo: &Path, anchor: &Anchor) -> Result<String, Error> {
- let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
- let blob = ObjectId::try_from(&anchor.blob)
- .map_err(|_error| Error::Resolve(anchor.blob.to_string()))?;
- let data = read_blob(&repo, blob)?;
- match anchor.lines {
- None => Ok(String::from_utf8_lossy(&data).into_owned()),
- Some(range) => lines_of(&data, &anchor.path, range),
- }
-}
-
-/// The text of the 1-based inclusive `range` within `data`, or
-/// [`Error::LinesOutOfRange`] (naming `path`) when the range does not fit.
-fn lines_of(data: &[u8], path: &str, range: LineRange) -> Result<String, Error> {
- let all: Vec<&[u8]> = data.lines_with_terminator().collect();
- let out_of_range = || Error::LinesOutOfRange {
- path: path.to_owned(),
- start: range.start,
- end: range.end,
- len: u64::try_from(all.len()).unwrap_or(u64::MAX),
- };
- // One slice lookup validates the whole range: start == 0 dies in
- // checked_sub, an inverted or oversized range dies in get.
- let first = usize::try_from(range.start)
- .ok()
- .and_then(|start| start.checked_sub(1))
- .ok_or_else(out_of_range)?;
- let last = usize::try_from(range.end).ok().ok_or_else(out_of_range)?;
- let bytes = all.get(first..last).ok_or_else(out_of_range)?.concat();
- Ok(String::from_utf8_lossy(&bytes).into_owned())
-}
-
-/// Project `anchor` onto `target` (a revision in `repo`): the fast path
-/// returns [`Projection::Current`] when the target tree holds the anchor's
-/// blob at its path; otherwise the anchor commit's tree is diffed against the
-/// target's with rename tracking to find where the file went, and the line
-/// range is mapped through the blob diff's hunks — shifted past edits that
-/// land entirely outside it, [`Projection::Outdated`] when an edit touches it.
-///
-/// Fails with [`Error::AnchorCommitMissing`] when `anchor.commit` no longer
-/// exists (it is retained on a best-effort basis only); a caller that also
-/// holds the anchor's [`context`] should retry with [`project_from_context`]
-/// in that case.
-///
-/// ## Requirements
-///
-/// @relation(comments.projection)
-pub fn project(repo: &Path, anchor: &Anchor, target: &str) -> Result<Projection, Error> {
- let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
- let anchor_blob = ObjectId::try_from(&anchor.blob)
- .map_err(|_error| Error::Resolve(anchor.blob.to_string()))?;
- let anchor_commit_id = ObjectId::try_from(&anchor.commit)
- .map_err(|_error| Error::Resolve(anchor.commit.to_string()))?;
- let target_commit = resolve_commit(&repo, target)?;
- let target_tree = target_commit
- .tree()
- .map_err(|error| Error::Object(error.to_string()))?;
-
- if let Some(entry) = target_tree
- .lookup_entry_by_path(&anchor.path)
- .map_err(|error| Error::Object(error.to_string()))?
- && entry.mode().is_blob()
- && entry.object_id() == anchor_blob
- {
- return Ok(Projection::Current);
- }
-
- if !repo.has_object(anchor_commit_id) {
- return Err(Error::AnchorCommitMissing(anchor_commit_id));
- }
- let anchor_commit = commit_at(&repo, anchor_commit_id)?;
- let anchor_tree = anchor_commit
- .tree()
- .map_err(|error| Error::Object(error.to_string()))?;
- // Rename tracking is pinned to git's defaults (50% similarity, no copies)
- // rather than read from repository configuration, so a projection is the
- // same answer everywhere the repository is checked out.
- let options = gix::diff::Options::default().with_rewrites(Some(gix::diff::Rewrites::default()));
- let changes = repo
- .diff_tree_to_tree(Some(&anchor_tree), Some(&target_tree), options)
- .map_err(|error| Error::Diff(error.to_string()))?;
-
- // Find where the anchored path went: its old-side location is `location`
- // for a deletion or modification and `source_location` for a rename.
- let mut destination: Option<(String, ObjectId, bool)> = None;
- for change in changes {
- match change {
- Change::Deletion { location, .. } if location.as_bytes() == anchor.path.as_bytes() => {
- return Ok(Projection::FileDeleted);
- }
- Change::Modification {
- location,
- id,
- entry_mode,
- ..
- } if location.as_bytes() == anchor.path.as_bytes() => {
- destination = Some((anchor.path.clone(), id, entry_mode.is_blob()));
- break;
- }
- Change::Rewrite {
- source_location,
- location,
- id,
- entry_mode,
- copy: false,
- ..
- } if source_location.as_bytes() == anchor.path.as_bytes() => {
- destination = Some((
- location.to_str_lossy().into_owned(),
- id,
- entry_mode.is_blob(),
- ));
- break;
- }
- _ => {}
- }
- }
- let Some((path, blob, is_blob)) = destination else {
- // The diff never touched the path, yet the fast path did not match:
- // the anchor's blob is not what its own commit holds there, so the
- // anchor itself is broken.
- return Err(Error::MissingPath {
- commit: anchor_commit_id,
- path: anchor.path.clone(),
- });
- };
- if !is_blob {
- return Ok(Projection::Outdated { path });
- }
- if blob == anchor_blob {
- // A pure rename: the content is byte-identical, so every line is
- // exactly where it was.
- return Ok(Projection::Relocated {
- path,
- lines: anchor.lines,
- });
- }
- let lines = match anchor.lines {
- None => None,
- Some(range) => {
- let old = read_blob(&repo, anchor_blob)?;
- let new = read_blob(&repo, blob)?;
- match map_range(&old, &new, range) {
- Some(mapped) => Some(mapped),
- None => return Ok(Projection::Outdated { path }),
- }
- }
- };
- Ok(Projection::Relocated { path, lines })
-}
-
-/// How many lines of surrounding source [`context`] captures on each side of
-/// an anchored range — enough for [`project_from_context`]'s line-window scan
-/// to recognize the anchored lines' neighborhood even after they themselves
-/// moved a little, without dragging in unrelated parts of a large file.
-const CONTEXT_MARGIN: u64 = 3;
-
-/// The anchored range (or, for a whole-file anchor, the whole file) plus up to
-/// [`CONTEXT_MARGIN`] lines on either side, read from `anchor.blob` — a small,
-/// independently-retainable snapshot of the anchor's surroundings for
-/// [`project_from_context`] to fuzzy-match once `anchor.commit` itself is
-/// gone. The caller decides how (or whether) to retain the result; this
-/// function only derives it.
-///
-/// ## Requirements
-///
-/// @relation(comments.anchor)
-pub fn context(repo: &Path, anchor: &Anchor) -> Result<String, Error> {
- let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
- let blob = ObjectId::try_from(&anchor.blob)
- .map_err(|_error| Error::Resolve(anchor.blob.to_string()))?;
- let data = read_blob(&repo, blob)?;
- let Some(range) = anchor.lines else {
- return Ok(String::from_utf8_lossy(&data).into_owned());
- };
- let all: Vec<&[u8]> = data.lines_with_terminator().collect();
- let len = u64::try_from(all.len()).unwrap_or(u64::MAX);
- let start0 = range.start.saturating_sub(1);
- let margin_before = CONTEXT_MARGIN.min(start0);
- let ctx_start = start0.saturating_sub(margin_before);
- let margin_after = CONTEXT_MARGIN.min(len.saturating_sub(range.end));
- let ctx_end = range.end.saturating_add(margin_after).min(len);
- let (Ok(ctx_start), Ok(ctx_end)) = (usize::try_from(ctx_start), usize::try_from(ctx_end))
- else {
- return Ok(String::new());
- };
- let bytes = all.get(ctx_start..ctx_end).unwrap_or_default().concat();
- Ok(String::from_utf8_lossy(&bytes).into_owned())
-}
-
-/// Project `anchor` onto `target` by fuzzy-matching `context` (as produced by
-/// [`context`] at capture time) against `target`'s version of `anchor.path`,
-/// for use once `anchor.commit` no longer exists and [`project`] can no
-/// longer diff against its tree.
-///
-/// Looks up `anchor.path` in `target`'s tree directly (no rename tracking is
-/// possible without the anchor commit's tree, so a genuine rename reports
-/// [`Projection::FileDeleted`] here, same as a real deletion); a whole-file
-/// anchor (`anchor.lines` is `None`) survives any edit at that path, same as
-/// [`project`]. For a line-range anchor, every contiguous window of the
-/// target file's lines the same length as `context` is scored by how many
-/// lines match `context`'s exactly; the best-scoring window (at least half
-/// its lines matching) is accepted and the anchored sub-range is mapped back
-/// through the same margin [`context`] used to build it. No match clears that
-/// bar reports [`Projection::Outdated`], the same as an unrecoverable edit
-/// would under [`project`].
-///
-/// ## Requirements
-///
-/// @relation(comments.projection)
-pub fn project_from_context(
- repo: &Path,
- anchor: &Anchor,
- target: &str,
- context: &str,
-) -> Result<Projection, Error> {
- let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
- let target_commit = resolve_commit(&repo, target)?;
- let target_tree = target_commit
- .tree()
- .map_err(|error| Error::Object(error.to_string()))?;
- let Some(entry) = target_tree
- .lookup_entry_by_path(&anchor.path)
- .map_err(|error| Error::Object(error.to_string()))?
- else {
- return Ok(Projection::FileDeleted);
- };
- if !entry.mode().is_blob() {
- return Ok(Projection::Outdated {
- path: anchor.path.clone(),
- });
- }
- let Some(range) = anchor.lines else {
- return Ok(Projection::Relocated {
- path: anchor.path.clone(),
- lines: None,
- });
- };
-
- let data = read_blob(&repo, entry.object_id())?;
- let target_lines: Vec<&[u8]> = data.lines_with_terminator().collect();
- let context_lines: Vec<&[u8]> = context.as_bytes().lines_with_terminator().collect();
- let window = context_lines.len();
- if window == 0 || window > target_lines.len() {
- return Ok(Projection::Outdated {
- path: anchor.path.clone(),
- });
- }
-
- let mut best: Option<(usize, usize)> = None;
- for (start, slice) in target_lines.windows(window).enumerate() {
- let score = slice
- .iter()
- .zip(context_lines.iter())
- .filter(|(have, want)| have == want)
- .count();
- if best.is_none_or(|(_start, best_score)| score > best_score) {
- best = Some((start, score));
- }
- }
- // Require at least half the window's lines to match exactly, so an
- // unrelated coincidence of blank or near-empty lines is not mistaken for
- // the anchored region having relocated there.
- let Some((start, _score)) = best.filter(|(_start, score)| {
- score
- .checked_mul(2)
- .is_some_and(|doubled| doubled >= window)
- }) else {
- return Ok(Projection::Outdated {
- path: anchor.path.clone(),
- });
- };
-
- let margin_before = CONTEXT_MARGIN.min(range.start.saturating_sub(1));
- let range_len = range.end.saturating_sub(range.start).saturating_add(1);
- let Ok(start) = u64::try_from(start) else {
- return Ok(Projection::Outdated {
- path: anchor.path.clone(),
- });
- };
- let mapped_start = start.saturating_add(margin_before).saturating_add(1);
- let mapped_end = mapped_start.saturating_add(range_len).saturating_sub(1);
- Ok(Projection::Relocated {
- path: anchor.path.clone(),
- lines: Some(LineRange {
- start: mapped_start,
- end: mapped_end,
- }),
- })
-}
-
-/// Resolve `revision` (a hex id, ref name, or revspec) to the commit it names.
-fn resolve_commit<'repo>(
- repo: &'repo gix::Repository,
- revision: &str,
-) -> Result<gix::Commit<'repo>, Error> {
- let resolve = || Error::Resolve(revision.to_owned());
- repo.rev_parse_single(revision)
- .map_err(|_error| resolve())?
- .object()
- .map_err(|_error| resolve())?
- .peel_to_kind(gix::object::Kind::Commit)
- .map_err(|_error| resolve())?
- .try_into_commit()
- .map_err(|_error| resolve())
-}
-
-/// Look up the commit `id` names directly, with no revision parsing — for an
-/// [`Anchor`]'s own `commit`, which already names a concrete object rather
-/// than an arbitrary revision.
-fn commit_at(repo: &gix::Repository, id: ObjectId) -> Result<gix::Commit<'_>, Error> {
- let resolve = || Error::Resolve(id.to_string());
- repo.find_object(id)
- .map_err(|_error| resolve())?
- .peel_to_kind(gix::object::Kind::Commit)
- .map_err(|_error| resolve())?
- .try_into_commit()
- .map_err(|_error| resolve())
-}
-
-/// Read the full contents of the blob at `id`.
-fn read_blob(repo: &gix::Repository, id: ObjectId) -> Result<Vec<u8>, Error> {
- Ok(repo
- .find_blob(id)
- .map_err(|error| Error::Object(error.to_string()))?
- .take_data())
-}
-
-/// Map the 1-based inclusive `range` from `old`'s lines to `new`'s by walking
-/// the diff's hunks in order: a hunk entirely above the range shifts it by
-/// the hunk's growth, a hunk entirely below is ignored, and any hunk touching
-/// the range — including an insertion strictly inside it — means the anchored
-/// region itself changed, reported as `None` (outdated) rather than guessed
-/// at.
-///
-/// ## Requirements
-///
-/// @relation(comments.projection)
-fn map_range(old: &[u8], new: &[u8], range: LineRange) -> Option<LineRange> {
- // Work in 0-based half-open line coordinates, as the hunks do. Everything
- // stays unsigned: the shift is tallied as lines added and lines removed
- // above the range, and any overflow is an honest `None` (outdated) via the
- // checked arithmetic rather than a saturated wrong answer.
- let start = range.start.checked_sub(1)?;
- let end = range.end;
- if end <= start {
- return None;
- }
- let input = InternedInput::new(old, new);
- if end > u64::try_from(input.before.len()).ok()? {
- return None;
- }
- let diff = Diff::compute(Algorithm::Histogram, &input);
- let mut added: u64 = 0;
- let mut removed: u64 = 0;
- for hunk in diff.hunks() {
- let before_start = u64::from(hunk.before.start);
- let before_end = u64::from(hunk.before.end);
- if before_end <= start {
- removed = removed.checked_add(before_end.checked_sub(before_start)?)?;
- added = added
- .checked_add(u64::from(hunk.after.end).checked_sub(u64::from(hunk.after.start))?)?;
- } else if before_start >= end {
- break;
- } else {
- return None;
- }
- }
- let map = |line: u64| line.checked_add(added)?.checked_sub(removed);
- Some(LineRange {
- start: map(start)?.checked_add(1)?,
- end: map(end)?,
- })
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use git_store::test_support::{commit_all, head, repo};
-
- use super::*;
-
- fn numbered(range: std::ops::RangeInclusive<u32>) -> String {
- range.map(|n| format!("line {n}\n")).collect()
- }
-
- fn range(start: u64, end: u64) -> Option<LineRange> {
- Some(LineRange { start, end })
- }
-
- // @relation(comments.anchor, role=Verifies)
- #[test]
- fn capture_records_the_commit_and_blob_and_snippet_derives_the_text() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
-
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap();
- assert_eq!(anchor.commit.to_string(), head(dir.path()));
- assert_eq!(anchor.path, "file.txt");
- assert_eq!(anchor.lines, range(3, 4));
- assert!(!anchor.blob.to_string().is_empty());
- assert_eq!(snippet(dir.path(), &anchor).unwrap(), "line 3\nline 4\n");
- }
-
- // @relation(comments.anchor, role=Verifies)
- #[test]
- fn capture_rejects_a_missing_path_and_an_oversized_range() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=3)).unwrap();
- commit_all(dir.path(), "one");
-
- assert!(matches!(
- capture(dir.path(), "HEAD", "absent.txt", None),
- Err(Error::MissingPath { .. })
- ));
- assert!(matches!(
- capture(dir.path(), "HEAD", "file.txt", range(2, 9)),
- Err(Error::LinesOutOfRange { len: 3, .. })
- ));
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn unchanged_file_projects_as_current() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap();
-
- std::fs::write(dir.path().join("other.txt"), "unrelated\n").unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Current
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn an_edit_above_the_range_shifts_it() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
-
- let edited = format!("added a\nadded b\n{}", numbered(1..=10));
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: range(7, 8),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn an_edit_inside_the_range_is_outdated() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
-
- let edited = numbered(1..=10).replace("line 5\n", "line five\n");
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Outdated {
- path: "file.txt".to_owned(),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn a_pure_rename_relocates_with_the_same_lines() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap();
-
- std::fs::rename(dir.path().join("file.txt"), dir.path().join("moved.txt")).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Relocated {
- path: "moved.txt".to_owned(),
- lines: range(3, 4),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn a_rename_with_an_edit_above_relocates_and_shifts() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
-
- std::fs::remove_file(dir.path().join("file.txt")).unwrap();
- let edited = format!("added a\n{}", numbered(1..=10));
- std::fs::write(dir.path().join("moved.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Relocated {
- path: "moved.txt".to_owned(),
- lines: range(6, 7),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn a_deleted_file_projects_as_deleted() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap();
-
- std::fs::remove_file(dir.path().join("file.txt")).unwrap();
- std::fs::write(dir.path().join("unrelated.txt"), "different content\n").unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::FileDeleted
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn a_whole_file_anchor_survives_a_modification() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", None).unwrap();
- assert_eq!(snippet(dir.path(), &anchor).unwrap(), numbered(1..=10));
-
- let edited = numbered(1..=10).replace("line 5\n", "line five\n");
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project(dir.path(), &anchor, "HEAD").unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: None,
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn projection_works_backwards_onto_an_ancestor() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let old = head(dir.path());
-
- let edited = format!("added a\n{}", numbered(1..=10));
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(6, 7)).unwrap();
-
- assert_eq!(
- project(dir.path(), &anchor, &old).unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: range(5, 6),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn map_range_handles_edges() {
- let old = b"a\nb\nc\nd\n".as_slice();
- // An insertion exactly at the range start shifts it; one exactly at
- // its end leaves it alone.
- let above = b"x\na\nb\nc\nd\n".as_slice();
- assert_eq!(
- map_range(old, above, LineRange { start: 2, end: 3 }),
- Some(LineRange { start: 3, end: 4 })
- );
- // An insertion strictly inside the range outdates it.
- let inside = b"a\nb\nx\nc\nd\n".as_slice();
- assert_eq!(map_range(old, inside, LineRange { start: 2, end: 3 }), None);
- // A range past the end of the old file cannot map.
- assert_eq!(map_range(old, old, LineRange { start: 4, end: 9 }), None);
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn project_reports_the_anchor_commit_as_missing_once_it_is_gone() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let mut anchor = capture(dir.path(), "HEAD", "file.txt", range(3, 4)).unwrap();
-
- let edited = numbered(1..=10).replace("line 5\n", "line five\n");
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- // A made-up commit id that was never written to this repository
- // stands in for "gc'd away" without actually having to run gc in a
- // unit test — `has_object` answers `false` either way.
- anchor.commit = "0123456789abcdef0123456789abcdef01234567".into();
- assert!(matches!(
- project(dir.path(), &anchor, "HEAD"),
- Err(Error::AnchorCommitMissing(_))
- ));
- }
-
- // @relation(comments.anchor, role=Verifies)
- #[test]
- fn context_captures_a_margin_around_the_anchored_range() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
-
- // 3 lines of margin on each side of a 2-line range: lines 2..=9.
- let expected: String = (2..=9).map(|n| format!("line {n}\n")).collect();
- assert_eq!(context(dir.path(), &anchor).unwrap(), expected);
- }
-
- // @relation(comments.anchor, role=Verifies)
- #[test]
- fn context_clamps_to_the_file_when_the_margin_would_overrun_it() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=4)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(1, 2)).unwrap();
-
- assert_eq!(context(dir.path(), &anchor).unwrap(), numbered(1..=4));
- }
-
- // @relation(comments.anchor, role=Verifies)
- #[test]
- fn context_of_a_whole_file_anchor_is_the_whole_file() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=5)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", None).unwrap();
-
- assert_eq!(context(dir.path(), &anchor).unwrap(), numbered(1..=5));
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn project_from_context_relocates_across_an_edit_above_the_range() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
- let context = context(dir.path(), &anchor).unwrap();
-
- let edited = format!("added a\nadded b\n{}", numbered(1..=10));
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- // Same answer `project` itself would give, but derived with no
- // reference at all to the anchor's own (still very much present)
- // commit — exercising the exact code path that stands in once it is
- // gone.
- assert_eq!(
- project_from_context(dir.path(), &anchor, "HEAD", &context).unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: range(7, 8),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn project_from_context_reports_outdated_when_no_window_matches_well() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
- let context = context(dir.path(), &anchor).unwrap();
-
- // A wholesale rewrite leaves nothing resembling the captured
- // neighborhood anywhere in the file.
- std::fs::write(dir.path().join("file.txt"), "totally\nunrelated\ncontent\n").unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project_from_context(dir.path(), &anchor, "HEAD", &context).unwrap(),
- Projection::Outdated {
- path: "file.txt".to_owned(),
- }
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn project_from_context_reports_file_deleted() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", range(5, 6)).unwrap();
- let context = context(dir.path(), &anchor).unwrap();
-
- std::fs::remove_file(dir.path().join("file.txt")).unwrap();
- std::fs::write(dir.path().join("unrelated.txt"), "different\n").unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project_from_context(dir.path(), &anchor, "HEAD", &context).unwrap(),
- Projection::FileDeleted
- );
- }
-
- // @relation(comments.projection, role=Verifies)
- #[test]
- fn project_from_context_of_a_whole_file_anchor_survives_any_edit() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
- commit_all(dir.path(), "one");
- let anchor = capture(dir.path(), "HEAD", "file.txt", None).unwrap();
- let context = context(dir.path(), &anchor).unwrap();
-
- let edited = numbered(1..=10).replace("line 5\n", "line five\n");
- std::fs::write(dir.path().join("file.txt"), edited).unwrap();
- commit_all(dir.path(), "two");
-
- assert_eq!(
- project_from_context(dir.path(), &anchor, "HEAD", &context).unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: None,
- }
- );
- }
-}
crates/git-backend/Cargo.toml
@@ -1,14 +1,0 @@
-[package]
-name = "git-backend"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-thiserror = { workspace = true }
-
-[lints]
-workspace = true
crates/git-backend/src/cache_ns.rs
@@ -1,116 +1,0 @@
-//! The cache ref namespaces (`docs/scale-out.adoc`, correctness rule 4)
-//! and the one lookup both their writer (`git-cache-proxy`) and their
-//! maintainer (`git-maintenance`, WS9) must agree on.
-//!
-//! Rule 4's contract: `refs/cache/*` and `refs/meta/cache/*` are
-//! evictable, reconstructible, and exempt from provenance. Concurrent
-//! writers use per-key refs; a consolidation effect — the only multi-ref
-//! cache writer — compacts them into one tree under a consolidated ref.
-//! After a consolidation, a key's bytes are reachable through *either* its
-//! per-key ref (not yet consolidated) or the consolidated tree (already
-//! compacted); [`resolve`] is the read path that consults both, defined
-//! here so the proxy's GET and the maintenance tests resolve keys through
-//! the identical code.
-
-use gix_hash::ObjectId;
-
-use crate::{ObjectStore, RefName, RefStore, Result};
-
-/// Every cache ref namespace (`docs/scale-out.adoc`, rule 4). Anything
-/// under these prefixes is evictable and reconstructible; nothing outside
-/// them is ever treated as cache by maintenance.
-pub const CACHE_PREFIXES: [&str; 2] = ["refs/cache/", "refs/meta/cache/"];
-
-/// Whether `name` lies in a cache namespace ([`CACHE_PREFIXES`]).
-#[must_use]
-pub fn is_cache_ref(name: &RefName) -> bool {
- CACHE_PREFIXES
- .iter()
- .any(|prefix| name.as_str().starts_with(prefix))
-}
-
-/// The prefix per-key cache refs for `namespace` live under —
-/// `refs/cache/<namespace>/`, one ref per key below it.
-#[must_use]
-pub fn per_key_prefix(namespace: &str) -> RefName {
- RefName::new(format!("refs/cache/{namespace}/"))
-}
-
-/// The ref the consolidation effect compacts `namespace`'s per-key refs
-/// into: points at a tree whose path `<key>` holds the key's blob.
-/// Deliberately *not* under [`per_key_prefix`] (a sibling `consolidated/`
-/// namespace instead), so it can never collide with a key's own ref — and
-/// it stays inside `refs/cache/`, so rule 4 (evictable, own packs, exempt
-/// from provenance) applies to it exactly as to the refs it replaces.
-#[must_use]
-pub fn consolidated_ref(namespace: &str) -> RefName {
- RefName::new(format!("refs/cache/consolidated/{namespace}"))
-}
-
-/// Resolve cache key `key` in `namespace` to its blob's id: the per-key
-/// ref first — one ref lookup, a hit for every key written since the last
-/// consolidation, keeping the common-case GET at a single lookup — then
-/// the consolidated tree (one ref lookup plus a tree descent) for keys
-/// already compacted. `None` when neither knows the key.
-///
-/// Per-key-first is also the *correct* order, not just the fast one: the
-/// consolidation transaction deletes a per-key ref in the same atomic
-/// multi-ref transaction that publishes the consolidated tree, so a
-/// present per-key ref is always current, never a stale shadow of a
-/// consolidated entry.
-///
-/// # Errors
-///
-/// Returns an error if the ref store or object store fails, or if a
-/// consolidated tree object is malformed.
-pub fn resolve(
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
- namespace: &str,
- key: &str,
-) -> Result<Option<ObjectId>> {
- let per_key = RefName::new(format!("refs/cache/{namespace}/{key}"));
- if let Some(oid) = refs.get(&per_key)? {
- return Ok(Some(oid));
- }
- let Some(root) = refs.get(&consolidated_ref(namespace))? else {
- return Ok(None);
- };
- tree_path(objects, root, key)
-}
-
-/// Descend from tree `root` along `/`-separated `path`, returning the id
-/// the final segment names, or `None` if any segment is absent.
-///
-/// # Errors
-///
-/// Returns an error if an object read fails or a tree is malformed.
-pub fn tree_path(
- objects: &dyn ObjectStore,
- root: ObjectId,
- path: &str,
-) -> Result<Option<ObjectId>> {
- let mut current = root;
- let mut segments = path.split('/').peekable();
- while let Some(segment) = segments.next() {
- let object = objects.read(current)?;
- if object.kind != gix_object::Kind::Tree {
- return Ok(None);
- }
- let tree = gix_object::TreeRef::from_bytes(&object.data, gix_hash::Kind::Sha1)
- .map_err(|error| crate::Error::ObjectStore(format!("malformed tree: {error}")))?;
- let Some(entry) = tree
- .entries
- .iter()
- .find(|entry| entry.filename == segment.as_bytes())
- else {
- return Ok(None);
- };
- let child = entry.oid.to_owned();
- if segments.peek().is_none() {
- return Ok(Some(child));
- }
- current = child;
- }
- Ok(None)
-}
crates/git-backend/src/effect.rs
@@ -1,90 +1,0 @@
-//! [`EffectExecutor`]: the seam between the effect engine and wherever an
-//! effect actually runs.
-
-use crate::Result;
-
-/// The static definition of an effect to spawn: its name and the command
-/// run for it (`None` for a composite effect that only aggregates
-/// dependencies elsewhere), plus the sandbox image it runs in when it names
-/// one. Mirrors the shape `git_effect::Effect` loads from
-/// `refs/meta/effects/<name>` — kept as an independent, minimal type here
-/// (rather than a dependency on `git-effect`) so this foundational crate
-/// stays below the effect engine in the dependency graph, not above it.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EffectDef {
- /// The name it is stored under (`refs/meta/effects/<name>`).
- pub name: String,
- /// The shell command run for the effect, or `None` for a composite
- /// effect that only aggregates its dependencies.
- pub command: Option<String>,
- /// The sandbox image the command runs in; `None` uses the default.
- pub image: Option<String>,
-}
-
-/// The materialized, ready-to-run inputs [`EffectExecutor::spawn`] hands to
-/// a backend: the tree an effect runs against, each activated toolchain's
-/// resolved `PATH` entry (keyed by toolchain name), and its cache
-/// directory if it has one. Assembling these is "materialization"
-/// (`docs/scale-out.adoc` correctness rule 6): manifest lookup,
-/// `ObjectStore` read, hash verification, then handed here — the same one
-/// code path regardless of which tier answered the read.
-#[derive(Debug, Clone)]
-pub struct MaterializedInputs {
- /// The tree the effect runs against.
- pub tree: gix_hash::ObjectId,
- /// Each activated toolchain's resolved `PATH` entry, keyed by name.
- pub toolchain_paths: std::collections::BTreeMap<String, String>,
- /// The effect's cache *name*, if it declares one. A name, not a path:
- /// where the cache lands is a property of each backend's own sandbox
- /// layout (a bind-mounted `/cache/<name>` in a local container, a
- /// persistent directory in a Sprite), so the backend maps the name
- /// itself — mirroring how the effect engine's backends each derive
- /// their own cache directory for the same declared cache.
- pub cache: Option<String>,
-}
-
-/// A handle to a spawned effect, returned by [`EffectExecutor::spawn`] and
-/// consumed by [`EffectExecutor::wait`]. The id is backend-chosen and
-/// opaque to callers: a worker-thread key for `exec-local`, a Fly Machine
-/// id for `exec-sprites`.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EffectHandle {
- /// A backend-chosen opaque identifier for the spawned effect.
- pub id: String,
-}
-
-/// The terminal state of a spawned effect, as observed by the
-/// [`EffectExecutor`] that ran it (see [`EffectExecutor::wait`]).
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum EffectStatus {
- /// The command ran to completion and exited zero.
- Pass,
- /// The command ran to completion and exited non-zero.
- Fail,
- /// The executor could not run the command to an observable exit (a
- /// sandbox that would not start, a timeout, a lost worker).
- Error,
- /// The effect settled, but its outcome is not observable through this
- /// executor: it returns out-of-band, via the attested push of its
- /// results and cache refs with a worker member key
- /// (`docs/scale-out.adoc`, WS7 — the `exec-sprites` path, where the
- /// machine's termination tells the dispatcher only that the effect
- /// settled, and the recorded run refs carry the outcome).
- SettledRemotely,
-}
-
-/// Where a [`MaterializedInputs::tree`] actually runs: a sandboxed
-/// subprocess today (`exec-local`), a Fly Machine hosted (`exec-sprites`).
-/// Application code (the effect engine, the WS7 dispatcher) is written once
-/// against this trait; which backend answers `spawn` is a deployment
-/// detail.
-pub trait EffectExecutor: Send + Sync {
- /// Spawn `effect` against `inputs`, returning a handle to the running
- /// effect. Does not block for completion.
- fn spawn(&self, effect: &EffectDef, inputs: MaterializedInputs) -> Result<EffectHandle>;
-
- /// Block until the effect behind `handle` settles, returning the
- /// terminal state this executor could observe. Consumes the handle's
- /// backend-side state: waiting twice on one handle is an error.
- fn wait(&self, handle: &EffectHandle) -> Result<EffectStatus>;
-}
crates/git-backend/src/lib.rs
@@ -1,61 +1,0 @@
-//! Backend-agnostic storage traits for git-ents.
-//!
-//! [`RefStore`], [`ObjectStore`], and [`EffectExecutor`] are the seams the
-//! development plan (`docs/scale-out.adoc`, "Storage traits") draws between
-//! application logic and where repository state actually lives. Application
-//! code is written once, against these traits; a local backend
-//! (`refstore-files`, `odb-files`) and a future cloud backend
-//! (`refstore-postgres`, `odb-tigris`) both satisfy the same contract,
-//! checked by a conformance suite (WS2) rather than assumed.
-//!
-//! # Why these three
-//!
-//! - [`RefStore`] is the unit of correctness: every write to repository
-//! state is a ref transaction, and multi-ref compare-and-swap is
-//! contractual, not optional.
-//! - [`ObjectStore`] is deliberately narrower than a full git object
-//! database: there is no `write_loose`, because a remote object tier
-//! (Tigris) cannot offer one. Objects arrive as packs, staged in
-//! quarantine until the ref transaction that makes them reachable
-//! commits.
-//! - [`EffectExecutor`] is the seam between the effect engine and wherever
-//! an effect actually runs (a local sandbox today, a Fly Sprite later).
-//!
-//! See `docs/scale-out.adoc` for the full rationale, the correctness rules
-//! that bind every backend, and the workstream this crate implements (WS1).
-
-pub mod cache_ns;
-mod effect;
-mod object_store;
-mod ref_store;
-
-pub use effect::{EffectDef, EffectExecutor, EffectHandle, EffectStatus, MaterializedInputs};
-pub use object_store::{Object, ObjectStore, PackStream, QuarantineId};
-pub use ref_store::{
- Expected, RefEdit, RefEvent, RefEventStream, RefIter, RefLogEntry, RefLogIter, RefName,
- RefStore, TxOutcome,
-};
-
-/// A failure in a [`RefStore`], [`ObjectStore`], or [`EffectExecutor`]
-/// implementation. Shared across all three traits so application code
-/// handles storage failures uniformly regardless of which seam raised them.
-#[derive(Debug, thiserror::Error)]
-pub enum Error {
- /// A [`RefStore`] operation failed for a reason other than a
- /// compare-and-swap mismatch, which is reported as
- /// [`TxOutcome::Rejected`] rather than an `Err`.
- #[error("ref store operation failed: {0}")]
- RefStore(String),
- /// An [`ObjectStore`] operation failed.
- #[error("object store operation failed: {0}")]
- ObjectStore(String),
- /// An [`EffectExecutor`] operation failed.
- #[error("effect executor operation failed: {0}")]
- Effect(String),
- /// An underlying I/O error.
- #[error("i/o error: {0}")]
- Io(#[from] std::io::Error),
-}
-
-/// This crate's `Result` alias.
-pub type Result<T> = std::result::Result<T, Error>;
crates/git-backend/src/object_store.rs
@@ -1,96 +1,0 @@
-//! [`ObjectStore`]: content-addressed object storage, staged then promoted.
-
-use gix_hash::ObjectId;
-
-use crate::Result;
-
-/// A single object read back from an [`ObjectStore`]: its kind and its raw,
-/// undeltified content.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Object {
- /// The object's kind (blob, tree, commit, or tag).
- pub kind: gix_object::Kind,
- /// The object's raw content.
- pub data: Vec<u8>,
-}
-
-/// An incoming pack of objects, not yet indexed or validated, handed to
-/// [`ObjectStore::stage_pack`]. Wraps whatever byte source the caller has —
-/// a network connection, a file, an in-memory buffer — behind one type so
-/// the trait stays object-safe.
-pub struct PackStream(Box<dyn std::io::Read + Send>);
-
-impl PackStream {
- /// Wrap `reader` as a [`PackStream`].
- pub fn new(reader: impl std::io::Read + Send + 'static) -> Self {
- Self(Box::new(reader))
- }
-}
-
-impl std::io::Read for PackStream {
- fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
- self.0.read(buf)
- }
-}
-
-/// A handle to a pack staged in quarantine by [`ObjectStore::stage_pack`],
-/// passed back to [`ObjectStore::promote`] once the ref transaction that
-/// makes its objects reachable has committed.
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct QuarantineId(String);
-
-impl QuarantineId {
- /// Build a `QuarantineId` from a backend-chosen opaque token.
- pub fn new(id: impl Into<String>) -> Self {
- Self(id.into())
- }
-
- /// The id as a `&str`.
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-impl std::fmt::Display for QuarantineId {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-/// Content-addressed object storage. Deliberately narrower than a full git
-/// object database: there is no `write_loose`, because the tiered remote
-/// backend (Tigris) this trait is also meant to describe cannot offer one —
-/// small writes route through a small-object tier as ordinary staged
-/// writes instead (see `docs/scale-out.adoc`, "ObjectStore").
-///
-/// # Contract
-///
-/// - **Staged objects are invisible to reachability walks and GC.**
-/// [`stage_pack`](Self::stage_pack) places objects in quarantine; until
-/// [`promote`](Self::promote) is called, [`read`](Self::read) and
-/// [`contains`](Self::contains) against the promoted view must not see
-/// them, and no reachability walk or collection may visit them either.
-/// - **Ref transactions are the only commit point.** An object becomes
-/// reachable only once the ref transaction pointing at it (or at
-/// something that reaches it) has committed — `promote` makes objects
-/// visible, it does not itself make them reachable.
-pub trait ObjectStore: Send + Sync {
- /// Read the object `id`, erroring if it is not present in the promoted
- /// (non-quarantined) store.
- fn read(&self, id: ObjectId) -> Result<Object>;
-
- /// Whether `id` is present in the promoted (non-quarantined) store.
- fn contains(&self, id: ObjectId) -> Result<bool>;
-
- /// Index `pack` into quarantine, invisible to [`read`](Self::read) and
- /// [`contains`](Self::contains) until [`promote`](Self::promote) is
- /// called on the returned id.
- fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId>;
-
- /// Make the pack staged under `q` visible to [`read`](Self::read) and
- /// [`contains`](Self::contains). Callers must not call this before the
- /// ref transaction that makes the pack's objects reachable has
- /// committed — see the trait's contract above.
- fn promote(&self, q: QuarantineId) -> Result<()>;
-}
crates/git-backend/src/ref_store.rs
@@ -1,225 +1,0 @@
-//! [`RefStore`]: the unit of correctness for repository state.
-
-use gix_hash::ObjectId;
-
-use crate::Result;
-
-/// A full ref name (`refs/heads/main`) or a ref-namespace prefix
-/// (`refs/meta/`), used with [`RefStore::iter_prefix`] and
-/// [`RefStore::watch`]. Backend-agnostic: it carries no assumption about
-/// whether the underlying store is gitoxide loose refs, a Postgres row, or
-/// anything else.
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RefName(String);
-
-impl RefName {
- /// Build a `RefName` from any owned-or-borrowed string.
- pub fn new(name: impl Into<String>) -> Self {
- Self(name.into())
- }
-
- /// The ref name as a `&str`.
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-impl From<&str> for RefName {
- fn from(name: &str) -> Self {
- Self::new(name)
- }
-}
-
-impl From<String> for RefName {
- fn from(name: String) -> Self {
- Self::new(name)
- }
-}
-
-impl AsRef<str> for RefName {
- fn as_ref(&self) -> &str {
- self.as_str()
- }
-}
-
-impl std::fmt::Display for RefName {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-/// 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 [`ObjectId`].
- MustExistAndMatch(ObjectId),
-}
-
-/// One ref's half of a [`RefStore::transaction`] batch: what `name` is
-/// expected to hold, and what it should become. `new: None` deletes the
-/// ref.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RefEdit {
- /// The ref this edit applies to.
- pub name: RefName,
- /// 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 [`RefStore::transaction`] call that itself completed
-/// (returned `Ok`): either every edit applied, or none did.
-#[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: RefName,
- },
-}
-
-/// An iterator over `(name, tip)` pairs from a [`RefStore::iter_prefix`]
-/// query, wrapping whatever iterator the backend produces so the trait
-/// itself stays object-safe.
-pub struct RefIter(Box<dyn Iterator<Item = Result<(RefName, ObjectId)>> + Send>);
-
-impl RefIter {
- /// Wrap `iter` as a [`RefIter`].
- pub fn new(iter: impl Iterator<Item = Result<(RefName, ObjectId)>> + Send + 'static) -> Self {
- Self(Box::new(iter))
- }
-}
-
-impl Iterator for RefIter {
- type Item = Result<(RefName, ObjectId)>;
-
- fn next(&mut self) -> Option<Self::Item> {
- self.0.next()
- }
-}
-
-/// One entry in a ref's log: the value it moved from and to, the message
-/// recorded with the change, and when it happened.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RefLogEntry {
- /// The ref's value before this entry, or `None` when the ref was
- /// created by it.
- pub old: Option<ObjectId>,
- /// The ref's value after this entry, or `None` when the ref was
- /// deleted by it.
- pub new: Option<ObjectId>,
- /// The message recorded with the change.
- pub message: String,
- /// When the change happened, in seconds since the epoch.
- pub seconds: u64,
-}
-
-/// An iterator over a ref's [`RefLogEntry`] history, most recent first.
-pub struct RefLogIter(Box<dyn Iterator<Item = Result<RefLogEntry>> + Send>);
-
-impl RefLogIter {
- /// Wrap `iter` as a [`RefLogIter`].
- pub fn new(iter: impl Iterator<Item = Result<RefLogEntry>> + Send + 'static) -> Self {
- Self(Box::new(iter))
- }
-}
-
-impl Iterator for RefLogIter {
- type Item = Result<RefLogEntry>;
-
- fn next(&mut self) -> Option<Self::Item> {
- self.0.next()
- }
-}
-
-/// A wakeup hint delivered by a [`RefEventStream`]. Carries no payload: per
-/// [`RefStore::watch`]'s contract, a consumer never trusts the event's
-/// content, only that *something* changed under the watched prefix, and
-/// re-drains its own source of truth (a queue table, a fresh
-/// [`RefStore::iter_prefix`]) in response.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RefEvent;
-
-/// A best-effort stream of [`RefEvent`] wakeup hints from
-/// [`RefStore::watch`]. Delivery is not guaranteed: a hint can be delayed,
-/// coalesced, or dropped entirely (e.g. across a reconnect). Every consumer
-/// must therefore drain its own durable state on every wakeup *and* on
-/// reconnect, never relying on this stream to have delivered exactly one
-/// event per change.
-pub struct RefEventStream {
- receiver: std::sync::mpsc::Receiver<RefEvent>,
-}
-
-impl RefEventStream {
- /// Wrap `receiver` as a [`RefEventStream`].
- #[must_use]
- pub fn new(receiver: std::sync::mpsc::Receiver<RefEvent>) -> Self {
- Self { receiver }
- }
-
- /// Block until the next wakeup hint, or `None` once the backend's
- /// watcher has shut down.
- pub fn recv(&self) -> Option<RefEvent> {
- self.receiver.recv().ok()
- }
-
- /// Block for up to `timeout` for the next wakeup hint.
- pub fn recv_timeout(&self, timeout: std::time::Duration) -> Option<RefEvent> {
- self.receiver.recv_timeout(timeout).ok()
- }
-}
-
-/// The unit of correctness for repository state: a store of named refs,
-/// each pointing at an [`ObjectId`], updated only through atomic
-/// transactions.
-///
-/// # 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.
-/// - **`watch` is a best-effort wakeup hint, never a source of truth.** The
-/// effect queue table (or equivalent durable state) is what carries the
-/// at-least-once guarantee; a consumer must drain it on every wakeup and
-/// on reconnect, not trust that one hint means exactly one change.
-/// - **`log` is the ref's own history**, independent of the store's queue —
-/// an audit trail, not a delivery mechanism.
-pub trait RefStore: Send + Sync {
- /// The object id `name` currently points at, or `None` if `name` does
- /// not exist.
- fn get(&self, name: &RefName) -> Result<Option<ObjectId>>;
-
- /// Every ref under `prefix`, with its current tip.
- fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter>;
-
- /// Apply `edits` as one atomic compare-and-swap transaction: every
- /// edit's [`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>;
-
- /// Subscribe to a best-effort wakeup hint whenever a ref under `prefix`
- /// changes. See the trait's contract above: delivery is not
- /// guaranteed, and no consumer may treat this stream as a source of
- /// truth.
- fn watch(&self, prefix: &RefName) -> Result<RefEventStream>;
-
- /// `name`'s history, most recent entry first.
- fn log(&self, name: &RefName) -> Result<RefLogIter>;
-}
crates/git-cache-proxy/Cargo.toml
@@ -1,24 +1,0 @@
-[package]
-name = "git-cache-proxy"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-axum = { workspace = true }
-git-backend = { workspace = true }
-git-ents-core = { workspace = true }
-git-member = { workspace = true }
-git-protocol = { workspace = true }
-gix-reachability = { workspace = true }
-git-store = { workspace = true }
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-odb-files = { workspace = true }
-refstore-files = { workspace = true }
-tempfile = { workspace = true }
-tokio = { workspace = true }
-
-[lints]
-workspace = true
crates/git-cache-proxy/src/lib.rs
@@ -1,350 +1,0 @@
-//! An sccache HTTP cache proxy (`docs/scale-out.adoc`, "WS8 — Hydration and
-//! toolchains": "sccache: thin GET/PUT proxy. GET = tree-path lookup under
-//! the cache namespace; PUT = attested push to a per-key ref with the
-//! worker's member key. sccache never learns git.").
-//!
-//! `GET /{key}` resolves `key` straight to bytes via
-//! [`git_backend::RefStore`]/[`git_backend::ObjectStore`] against
-//! `refs/cache/sccache/<key>` — the cache-namespace carve-out rule 4
-//! grants: evictable, reconstructible, exempt from provenance but not from
-//! verification. Verification here *is* the lookup: the ref names an
-//! object id, `ObjectStore::read` either has bytes under that id or
-//! doesn't, so there is nothing to separately re-verify.
-//!
-//! `PUT /{key}` lands the request body as a real attested push to that
-//! same key's own ref, signed with the worker's member key, driven
-//! in-process through [`git_protocol::native::NativeBackend`] — the exact
-//! [`git_protocol::IngestPack`] implementation `git-ents-server`'s own
-//! `/_native/.../git-receive-pack` endpoint uses
-//! (`crates/git-ents-server/src/native_git.rs`), so a cache entry crosses
-//! the identical staged-then-connectivity-checked-then-atomic-then-
-//! promoted ordering and attestation check, and (unlike shelling out to
-//! `git push`) the write's server-signed op record comes back directly
-//! rather than being inferred. Per-key refs (one ref per cache key, never
-//! a shared mutable one) are the concurrent-writer story rule 4 calls for:
-//! two workers racing to cache the same compilation unit each land their
-//! own ref, no compare-and-swap contention between them. Consolidating the
-//! resulting many refs down to a bounded set of packs is a WS9 compaction
-//! effect's job, not this proxy's — it only ever adds refs, never merges
-//! or deletes them.
-//!
-//! sccache itself never learns any of this: from its side, this is a plain
-//! HTTP GET/PUT key-value service, the shape its `webdav`-type HTTP cache
-//! backend already speaks (`SCCACHE_WEBDAV_ENDPOINT`, optionally
-//! `SCCACHE_WEBDAV_TOKEN`).
-
-use std::path::{Path, PathBuf};
-use std::process::Command;
-use std::sync::Arc;
-use std::sync::atomic::{AtomicU64, Ordering};
-
-use axum::Router;
-use axum::body::Bytes;
-use axum::extract::{Path as AxumPath, State};
-use axum::http::{HeaderMap, StatusCode, header};
-use axum::response::{IntoResponse, Response};
-use axum::routing::get;
-use git_backend::{Expected, ObjectStore as _, PackStream, RefEdit, RefName};
-use git_protocol::attestation::{OpSigner, SshOpSigner};
-use git_protocol::native::{BackendResolver, NativeBackend, RepoBackends};
-use git_protocol::{IngestPack as _, PushCertificate, PushOutcome, PushRequest, RepoId};
-
-/// The cache namespace sccache entries live in (see
-/// [`git_backend::cache_ns`]): per-key refs under
-/// `refs/cache/sccache/<key>`, consolidated reads under
-/// `refs/cache/consolidated/sccache` once WS9's compaction has run.
-pub const CACHE_NAMESPACE: &str = "sccache";
-
-/// The ref namespace sccache entries land under: one ref per key,
-/// `refs/cache/sccache/<key>`.
-pub const CACHE_NS: &str = "refs/cache/sccache";
-
-/// Configuration for [`router`].
-pub struct Config {
- /// The bare repository cache entries are read from and pushed to.
- pub repo: PathBuf,
- /// The worker's own SSH signing key — "the worker's member key" per
- /// the design doc — used both to sign every `PUT`'s push certificate
- /// (proving a `PUT` speaks for an enrolled member) and, as this
- /// proxy's own [`OpSigner`], the resulting op record (proving the push
- /// was accepted). A real deployment with a separate server identity
- /// would split these; this proxy has exactly one identity to offer, so
- /// it plays both roles rather than inventing a second key this crate
- /// has no way to provision. `None` disables `PUT` (`405 Method Not
- /// Allowed`) — a proxy with no key cannot attest a write, so refusing
- /// beats silently downgrading to an unattested one.
- pub signing_key: Option<PathBuf>,
- /// Bearer token sccache must present as `Authorization: Bearer
- /// <token>`, matching the credential shape sccache's own `webdav`
- /// cache backend supports (`SCCACHE_WEBDAV_TOKEN`). `None` disables the
- /// check, for a proxy already bound to a private network with no
- /// exposed port.
- pub token: Option<String>,
-}
-
-/// This proxy's request counters, exposed the same way
-/// [`odb_baked::BakedTier::counters`] exposes its own — an operator-visible
-/// surface, not just a debugging aid.
-#[derive(Debug, Default)]
-pub struct Counters {
- /// `GET`s that found a cache entry.
- pub hits: AtomicU64,
- /// `GET`s that found no cache entry (`404`).
- pub misses: AtomicU64,
- /// `PUT`s that landed a new attested push.
- pub puts: AtomicU64,
-}
-
-#[derive(Clone)]
-struct AppState {
- config: Arc<Config>,
- counters: Arc<Counters>,
-}
-
-/// Build the sccache proxy's [`Router`] (`GET`/`PUT /{*key}`) and a handle
-/// to its request counters.
-#[must_use = "the router must be mounted (e.g. `axum::serve`) or the counters handle is useless"]
-pub fn router(config: Config) -> (Router, Arc<Counters>) {
- let counters = Arc::new(Counters::default());
- let state = AppState {
- config: Arc::new(config),
- counters: counters.clone(),
- };
- let router = Router::new()
- .route("/{*key}", get(get_object).put(put_object))
- .with_state(state);
- (router, counters)
-}
-
-/// Whether `headers` carries the bearer token `config` requires, or `true`
-/// unconditionally when `config.token` is unset.
-fn authorized(config: &Config, headers: &HeaderMap) -> bool {
- let Some(token) = &config.token else {
- return true;
- };
- headers
- .get(header::AUTHORIZATION)
- .and_then(|value| value.to_str().ok())
- .and_then(|value| value.strip_prefix("Bearer "))
- .is_some_and(|presented| presented == token)
-}
-
-/// The ref for cache key `key`, or `None` if any `/`-separated segment
-/// fails [`git_store::ref_segment_ok`] — rejecting a `..` segment, an empty
-/// segment, or anything else hostile to a git ref name before it ever
-/// becomes one.
-fn cache_ref(key: &str) -> Option<String> {
- let segments: Vec<&str> = key.split('/').collect();
- if segments.is_empty()
- || segments
- .iter()
- .any(|segment| !git_store::ref_segment_ok(segment))
- {
- return None;
- }
- Some(format!("{CACHE_NS}/{}", segments.join("/")))
-}
-
-async fn get_object(
- State(state): State<AppState>,
- AxumPath(key): AxumPath<String>,
- headers: HeaderMap,
-) -> Response {
- if !authorized(&state.config, &headers) {
- return StatusCode::UNAUTHORIZED.into_response();
- }
- if cache_ref(&key).is_none() {
- return StatusCode::BAD_REQUEST.into_response();
- }
- let repo = state.config.repo.clone();
- let outcome = tokio::task::spawn_blocking(move || read_entry(&repo, &key)).await;
- match outcome {
- Ok(Ok(Some(bytes))) => {
- state.counters.hits.fetch_add(1, Ordering::Relaxed);
- (StatusCode::OK, bytes).into_response()
- }
- Ok(Ok(None)) => {
- state.counters.misses.fetch_add(1, Ordering::Relaxed);
- StatusCode::NOT_FOUND.into_response()
- }
- Ok(Err(_)) | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
- }
-}
-
-/// Resolve `key` through [`git_backend::cache_ns::resolve`] — the per-key
-/// ref first (one lookup, the common case for entries written since the
-/// last consolidation), then the consolidated tree WS9's compaction
-/// effect maintains (`docs/scale-out.adoc`, rule 4) — then read the blob
-/// via [`odb_files::OdbFiles`]. The plain `RefStore`/`ObjectStore` read
-/// path the module doc calls out, no attested-push machinery involved
-/// (unlike [`write_entry`]: a read needs no attestation, only
-/// verification, and content-addressed lookup by object id already is
-/// that).
-fn read_entry(repo: &Path, key: &str) -> Result<Option<Vec<u8>>, git_backend::Error> {
- let refs = refstore_files::FilesRefStore::open(repo)?;
- let odb = odb_files::OdbFiles::open(repo)?;
- let Some(oid) = git_backend::cache_ns::resolve(&refs, &odb, CACHE_NAMESPACE, key)? else {
- return Ok(None);
- };
- let object = odb.read(oid)?;
- Ok(Some(object.data))
-}
-
-async fn put_object(
- State(state): State<AppState>,
- AxumPath(key): AxumPath<String>,
- headers: HeaderMap,
- body: Bytes,
-) -> Response {
- if !authorized(&state.config, &headers) {
- return StatusCode::UNAUTHORIZED.into_response();
- }
- let Some(signing_key) = state.config.signing_key.clone() else {
- return StatusCode::METHOD_NOT_ALLOWED.into_response();
- };
- let Some(refname) = cache_ref(&key) else {
- return StatusCode::BAD_REQUEST.into_response();
- };
- let repo = state.config.repo.clone();
- let body = body.to_vec();
- let outcome =
- tokio::task::spawn_blocking(move || write_entry(&repo, &refname, &body, &signing_key))
- .await;
- match outcome {
- Ok(Ok(())) => {
- state.counters.puts.fetch_add(1, Ordering::Relaxed);
- StatusCode::OK.into_response()
- }
- Ok(Err(_)) | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
- }
-}
-
-/// Resolves every push against the one configured repository's real
-/// `refstore-files`/`odb-files` backends and its currently enrolled
-/// members — the same resolution `git-ents-server`'s own native-protocol
-/// endpoint performs (`DiskResolver` in
-/// `crates/git-ents-server/src/native_git.rs`), loaded fresh on every call
-/// so a member enrolled or revoked between two `PUT`s is picked up
-/// immediately.
-struct DiskResolver {
- repo: PathBuf,
-}
-
-impl BackendResolver for DiskResolver {
- fn resolve(&self, _repo: &RepoId) -> git_protocol::Result<RepoBackends> {
- let refs = refstore_files::FilesRefStore::open(&self.repo)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let objects = odb_files::OdbFiles::open(&self.repo)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let members = git_member::members::load_all(&self.repo)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let revoked = git_member::revocations::fingerprints(&self.repo)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let config = git_ents_core::config::load(&self.repo)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- Ok(RepoBackends {
- refs: Arc::new(refs),
- objects: Arc::new(objects),
- authorized_members: git_member::members::without_revoked(members, &revoked),
- config,
- // This proxy targets the local-disk backend only; wiring
- // reachability artifacts is future work, same caveat
- // `DiskResolver` in `git-ents-server` carries (absence
- // degrades speed, never answers).
- reachability: gix_reachability::ArtifactBundle::empty(),
- })
- }
-}
-
-/// Land `body` as `target`'s content: hash it into a blob (never written to
-/// disk outside a pack, matching [`git_backend::ObjectStore`]'s "no
-/// `write_loose`" rule), build a one-object pack for it, sign a push
-/// certificate for `target`'s ref update with `signing_key`, and drive
-/// [`NativeBackend::receive`] — the real attested-push path, in-process.
-///
-/// Idempotent, not force-pushed: if `target` already holds a *different*
-/// blob than `body` hashes to, the ref update's `Expected::MustNotExist`
-/// (or, if some other write raced ahead of us, `NativeBackend::receive`'s
-/// own compare-and-swap) refuses it rather than clobbering whatever
-/// another worker already cached under the same key. A same-content
-/// re-`PUT` of an already-cached key is a checked no-op.
-fn write_entry(repo: &Path, target: &str, body: &[u8], signing_key: &Path) -> Result<(), String> {
- let oid = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, body)
- .map_err(|error| format!("could not hash the cache entry: {error}"))?;
-
- let refs = refstore_files::FilesRefStore::open(repo).map_err(|error| error.to_string())?;
- match git_backend::RefStore::get(&refs, &RefName::new(target))
- .map_err(|error| error.to_string())?
- {
- Some(existing) if existing == oid => return Ok(()),
- Some(_different) => {
- return Err(format!("{target} already holds a different cache entry"));
- }
- None => {}
- }
-
- let certificate = sign_push_cert(target, oid, signing_key)?;
- let pack = git_protocol::pack::build_pack(&[git_protocol::pack::PackObject {
- id: oid,
- kind: gix_object::Kind::Blob,
- data: body.to_vec(),
- }])
- .map_err(|error| error.to_string())?;
-
- let signer: Arc<dyn OpSigner> = Arc::new(SshOpSigner::new(signing_key.to_owned()));
- let backend = NativeBackend::new(
- DiskResolver {
- repo: repo.to_owned(),
- },
- signer,
- );
- let push = PushRequest {
- repo: RepoId::new("cache-proxy"),
- ref_edits: vec![RefEdit {
- name: RefName::new(target),
- expected: Expected::MustNotExist,
- new: Some(oid),
- }],
- pack: PackStream::new(std::io::Cursor::new(pack)),
- push_cert: Some(PushCertificate::new(certificate)),
- };
- match backend.receive(push).map_err(|error| error.to_string())? {
- PushOutcome::Accepted { .. } => Ok(()),
- PushOutcome::Rejected { reason } => Err(reason),
- }
-}
-
-/// Sign a minimal, real push certificate — `certificate version 0.1`
-/// payload for the single ref update `target` from unborn to `oid`, then an
-/// `ssh-keygen -Y sign -n git` signature block — the same shape
-/// [`git_signed_push::verify_certificate`] parses regardless of which write
-/// path produced it. The `pusher`/`pushee` fields are cosmetic:
-/// `git-member`'s `allowed_signers` lines use a wildcard principal, so
-/// verification never depends on their content, only on the signature
-/// itself matching an enrolled key.
-fn sign_push_cert(
- target: &str,
- oid: gix_hash::ObjectId,
- signing_key: &Path,
-) -> Result<String, String> {
- let null = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
- let payload = format!(
- "certificate version 0.1\npusher git-ents-cache-proxy <cache-proxy@git-ents>\npushee cache-proxy\nnonce \n\n{null} {oid} {target}\n"
- );
- let dir = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?;
- let payload_path = dir.path().join("payload");
- std::fs::write(&payload_path, &payload)
- .map_err(|error| format!("could not write the push certificate payload: {error}"))?;
- let status = Command::new("ssh-keygen")
- .args(["-q", "-Y", "sign", "-n", "git", "-f"])
- .arg(signing_key)
- .arg(&payload_path)
- .status()
- .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
- if !status.success() {
- return Err("could not sign the push certificate".to_owned());
- }
- let signature = std::fs::read_to_string(dir.path().join("payload.sig"))
- .map_err(|error| format!("could not read the push certificate signature: {error}"))?;
- Ok(format!("{payload}{signature}"))
-}
crates/git-cache-proxy/tests/proxy.rs
@@ -1,205 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::expect_used,
- clippy::panic,
- clippy::indexing_slicing,
- reason = "integration test binary"
-)]
-
-//! End-to-end coverage for the sccache proxy at the axum level: `PUT` then
-//! `GET` round-trips bytes, `GET` on an unknown key 404s, and a `PUT`
-//! really lands as an attested per-key cache ref with a server-signed op
-//! record — not just a local write.
-
-use std::collections::BTreeMap;
-use std::path::Path;
-use std::process::Command;
-
-use git_backend::{RefName, RefStore as _};
-use git_cache_proxy::{CACHE_NS, Config, router};
-use git_member::members::{Member, Provenance, Trust};
-use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
-use tokio::net::{TcpListener, TcpStream};
-
-/// A fresh bare repository, ready for `refstore-files`/`odb-files` to open.
-fn bare_repo() -> tempfile::TempDir {
- let dir = tempfile::tempdir().expect("tempdir");
- let status = Command::new("git")
- .args(["init", "-q", "--bare", "-b", "main"])
- .arg(dir.path())
- .status()
- .expect("run git init");
- assert!(status.success());
- dir
-}
-
-/// A fresh ed25519 keypair at `base/<name>`, returning `(private, public)`
-/// paths.
-fn keygen(base: &Path, name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
- let key = base.join(name);
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"])
- .arg(&key)
- .status()
- .expect("run ssh-keygen");
- assert!(status.success());
- (key.clone(), base.join(format!("{name}.pub")))
-}
-
-/// Enroll `public_key` as an admin-registered member of `repo` — the
-/// "worker's member key" every `PUT` in this test signs with.
-fn enroll(repo: &Path, public_key: &Path) {
- let key = std::fs::read_to_string(public_key).expect("read public key");
- let mut keys = BTreeMap::new();
- keys.insert("worker".to_owned(), key);
- let member = Member {
- principal: "worker".to_owned(),
- valid_after: None,
- valid_before: None,
- trust: Trust::Keys(keys),
- provenance: Provenance::AdminRegistered,
- account: None,
- role: None,
- };
- git_member::members::store(repo, &member).expect("enroll member");
-}
-
-/// Send a minimal HTTP/1.1 request over a fresh connection to `addr` and
-/// return `(status, body)`. `Connection: close` sidesteps keep-alive
-/// bookkeeping — a fresh connection per request is fine for a handful of
-/// assertions.
-async fn request(
- addr: std::net::SocketAddr,
- method: &str,
- path: &str,
- headers: &[(&str, &str)],
- body: &[u8],
-) -> (u16, Vec<u8>) {
- let mut stream = TcpStream::connect(addr).await.expect("connect");
- let mut request = format!(
- "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n",
- body.len()
- );
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str("\r\n");
- stream
- .write_all(request.as_bytes())
- .await
- .expect("write request");
- stream.write_all(body).await.expect("write body");
-
- // Deliberately not shutting down our write half here: hyper's server
- // treats a half-closed peer as an abrupt disconnect rather than "done
- // sending, still listening for the response" and drops the connection
- // without writing anything back. `Connection: close` in the request
- // above is enough for the server to close its own side once it has
- // written the response, which is what ends this `read_to_end`.
- let mut raw = Vec::new();
- stream.read_to_end(&mut raw).await.expect("read response");
- let header_end = raw
- .windows(4)
- .position(|window| window == b"\r\n\r\n")
- .unwrap_or_else(|| {
- panic!(
- "response has a header/body separator; raw ({} bytes) = {:?}",
- raw.len(),
- String::from_utf8_lossy(&raw)
- )
- });
- let header_text = String::from_utf8_lossy(&raw[..header_end]);
- let status: u16 = header_text
- .lines()
- .next()
- .and_then(|line| line.split_whitespace().nth(1))
- .and_then(|code| code.parse().ok())
- .expect("status line");
- let response_body = raw[header_end.saturating_add(4)..].to_vec();
- (status, response_body)
-}
-
-#[tokio::test(flavor = "multi_thread")]
-async fn put_then_get_round_trips_bytes_and_lands_an_attested_cache_ref() {
- let repo = bare_repo();
- let keys_dir = tempfile::tempdir().expect("tempdir");
- let (private, public) = keygen(keys_dir.path(), "worker");
- enroll(repo.path(), &public);
-
- let config = Config {
- repo: repo.path().to_owned(),
- signing_key: Some(private),
- token: Some("s3cret".to_owned()),
- };
- let (app, counters) = router(config);
- let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
- let addr = listener.local_addr().expect("local addr");
- tokio::spawn(async move {
- axum::serve(listener, app).await.expect("serve");
- });
-
- let auth = [("Authorization", "Bearer s3cret")];
- let body = b"sccache compiled artifact bytes";
-
- // GET on an unknown key 404s.
- let (status, _) = request(addr, "GET", "/no-such-key", &auth, b"").await;
- assert_eq!(status, 404);
-
- // An unauthenticated request is rejected before ever touching the repo.
- let (status, _) = request(addr, "GET", "/some-key", &[], b"").await;
- assert_eq!(status, 401);
-
- // PUT lands the entry.
- let (status, _) = request(addr, "PUT", "/some-key", &auth, body).await;
- assert_eq!(status, 200, "PUT should succeed");
-
- // GET returns exactly what was PUT.
- let (status, got) = request(addr, "GET", "/some-key", &auth, b"").await;
- assert_eq!(status, 200);
- assert_eq!(got, body);
-
- assert_eq!(counters.puts.load(std::sync::atomic::Ordering::Relaxed), 1);
- assert_eq!(counters.hits.load(std::sync::atomic::Ordering::Relaxed), 1);
- assert_eq!(
- counters.misses.load(std::sync::atomic::Ordering::Relaxed),
- 1
- );
-
- // The PUT really landed as a per-key ref under the cache namespace...
- let refs = refstore_files::FilesRefStore::open(repo.path()).expect("open ref store");
- let cache_ref = RefName::new(format!("{CACHE_NS}/some-key"));
- let cached_oid = refs
- .get(&cache_ref)
- .expect("read cache ref")
- .expect("cache ref exists");
- let expected_oid =
- gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, body).expect("hash");
- assert_eq!(cached_oid, expected_oid);
-
- // ...and a server-signed op record was emitted for it (the attested-push
- // path, not a bare local ref write).
- let op_log = refs
- .get(&RefName::new(git_protocol::attestation::OP_LOG_REF))
- .expect("read op log ref");
- assert!(op_log.is_some(), "PUT must emit an op record");
-}
-
-#[tokio::test(flavor = "multi_thread")]
-async fn put_is_disabled_without_a_signing_key() {
- let repo = bare_repo();
- let config = Config {
- repo: repo.path().to_owned(),
- signing_key: None,
- token: None,
- };
- let (app, _counters) = router(config);
- let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
- let addr = listener.local_addr().expect("local addr");
- tokio::spawn(async move {
- axum::serve(listener, app).await.expect("serve");
- });
-
- let (status, _) = request(addr, "PUT", "/some-key", &[], b"bytes").await;
- assert_eq!(status, 405);
-}
crates/git-comment/Cargo.toml
@@ -1,19 +1,0 @@
-[package]
-name = "git-comment"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-facet = { workspace = true }
-facet-git-tree = { workspace = true }
-git-anchor = { workspace = true }
-git-store = { workspace = true }
-gix = { workspace = true }
-
-[dev-dependencies]
-git-store = { workspace = true, features = ["test-support"] }
-
-[lints]
-workspace = true
crates/git-comment/src/lib.rs
@@ -1,721 +1,0 @@
-//! Comments on code, one per ref under `refs/meta/comments/<id>`.
-//!
-//! Each comment is a self-contained typed document on its own ref, read and
-//! written through [`git_store`] like an issue is, and anchored to a blob (and
-//! optionally a line range) through [`git_anchor`]: the stored [`Anchor`] is
-//! authoritative at creation and never mutated, and [`project`] re-derives at
-//! read time where the comment sits on any other commit.
-//!
-//! # Retention
-//!
-//! Nothing pins the anchored commit against garbage collection any more — its
-//! id on [`Anchor::commit`] is best-effort. What actually survives is the
-//! anchored *content*: [`store`] embeds the anchored blob directly (a tree
-//! entry pointing at its existing object id, no copy — content addressing
-//! makes this free) alongside a small `context` blob of the surrounding source
-//! lines ([`git_anchor::context`]), both as ordinary entries in the comment's
-//! own document tree. That makes them reachable — and so un-collectable — for
-//! as long as the comment's ref exists, with no gitlink and no second commit
-//! parent involved. [`project`] uses the context blob to fuzzy-match the
-//! anchor's location back onto a target commit once the anchor commit itself
-//! is gone (see [`git_anchor::project_from_context`]).
-//!
-//! # The comment is the commit
-//!
-//! The document tree holds only the body, the anchor, and an optional issue
-//! cross-reference (plus the retained blob and context, invisible to the
-//! public [`Comment`] type — see [`StoredComment`]). Who wrote the comment and
-//! when are *not* fields: they are recovered from the ref's commit chain
-//! ([`provenance`]) — the genesis commit's author created the comment, the
-//! tip commit's author last edited it — exactly as git itself carries
-//! authorship. [`store`] therefore takes the author and stamps it on the
-//! commit it writes.
-//!
-//! # Identity
-//!
-//! A comment's key is its ref's genesis hash, computed by [`new_id`] and never
-//! renamed: the object id of the object the comment derives from, or the hash
-//! of its own initial content when it derives from nothing. Cross-references
-//! key off this identifier, matching the issues collection's scheme.
-//!
-//! # Replies
-//!
-//! A reply is an ordinary comment whose [`Comment::reply_to`] names the
-//! parent comment's genesis id — but the edge is also cut into the commit
-//! graph itself: [`store`] resolves the parent's ref to its current tip and
-//! carries that commit as a second parent on the reply's genesis commit. That
-//! makes the reply a cryptographic happens-after proof of exactly which
-//! revision of the parent it answered, and gives thread reachability for
-//! free, without a second ref or an aggregation index. First-parent ancestry
-//! is unaffected: it stays the reply's own edit history, exactly as for any
-//! other comment (see [`provenance`]).
-
-use std::path::Path;
-
-use facet::Facet;
-use facet_git_tree::RawTree;
-use git_anchor::{Anchor, Projection};
-use git_store::Provenance;
-use gix::ObjectId;
-use gix::objs::tree::{Entry as TreeEntry, EntryKind, EntryMode};
-use gix::objs::{Blob, FindExt as _, Tree, Write as _};
-
-// @relation(comments.ref)
-/// The namespace under which comments are recorded: one ref,
-/// `refs/meta/comments/<id>`, per comment.
-pub const COMMENTS_NS: &str = "refs/meta/comments";
-
-/// One comment stored at `refs/meta/comments/<id>`. Author and timestamp are
-/// deliberately absent: they live on the ref's commits (see [`provenance`]).
-///
-/// ## Requirements
-///
-/// @relation(comments.ref, comments.authorship, comments.anchor, comments.projection)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Comment {
- /// The comment's body text.
- pub body: String,
- /// Where the comment was written — the commit, path, blob, and optional
- /// line range it was anchored to at creation.
- pub anchor: Anchor,
- /// The genesis id of the issue the comment belongs to, or `None` for a
- /// free-standing comment.
- pub issue: Option<String>,
- /// The genesis id of the comment this one replies to, or `None` for a
- /// top-level comment. The reply edge itself lives in the commit graph
- /// (see [`store`]); this field is a cheap-lookup mirror of it.
- pub reply_to: Option<String>,
-}
-
-/// The document actually written to and read from a comment's ref: [`Comment`]
-/// plus `retained`, a passthrough tree ([`facet_git_tree::RawTree`]) holding
-/// two entries invisible to [`Comment`] itself — `blob`, the anchored file at
-/// its own object id (a reference, not a copy: content addressing makes this
-/// free), and `context`, [`git_anchor::context`]'s snapshot of the
-/// surrounding lines. Both ride along in the comment's own document tree
-/// purely so they stay reachable from `refs/meta/comments/<id>` — and so
-/// survive force-push, branch deletion, and gc — for as long as the comment's
-/// ref exists, with no gitlink and no second commit parent involved.
-///
-/// `retained` is deliberately absent from the public [`Comment`]: it is
-/// storage plumbing a caller never needs to see, read, or set — [`store`]
-/// derives it fresh from `comment.anchor` every write.
-///
-/// ## Requirements
-///
-/// @relation(anchor.reachability)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct StoredComment {
- body: String,
- anchor: Anchor,
- issue: Option<String>,
- reply_to: Option<String>,
- retained: RawTree,
-}
-
-impl From<StoredComment> for Comment {
- fn from(stored: StoredComment) -> Self {
- Self {
- body: stored.body,
- anchor: stored.anchor,
- issue: stored.issue,
- reply_to: stored.reply_to,
- }
- }
-}
-
-/// Derive a comment's stable genesis key: `origin`'s object id (hex) when the
-/// comment derives from one, otherwise the hash of the comment's own initial
-/// content — every comment is a git object, so it always has one.
-pub fn new_id(origin: Option<&str>, content: &Comment) -> Result<String, git_store::Error> {
- git_store::new_id(origin, content)
-}
-
-/// Load the comment recorded at `refs/meta/comments/<id>` in `repo`, or `None`
-/// when no such comment exists.
-pub fn load(repo: &Path, id: &str) -> Result<Option<Comment>, git_store::Error> {
- Ok(git_store::Store::open(repo)?
- .load_item::<StoredComment>(COMMENTS_NS, id)?
- .map(Into::into))
-}
-
-/// Write `comment` to `refs/meta/comments/<id>` in `repo` as a new commit
-/// authored by `author` (a `(name, email)` pair), so the ref's commit chain is
-/// the comment's edit history and carries its authorship. Also embeds the
-/// anchored blob and a context snapshot in the written document tree (see
-/// [`StoredComment`]), so the content the comment is anchored to stays
-/// reachable independently of whether `comment.anchor.commit` itself survives.
-///
-/// When `comment.reply_to` is `Some`, the parent comment's ref must already
-/// exist: its current tip commit is looked up and carried as a second parent
-/// on the commit this call writes. Comment creation always starts a fresh
-/// ref, so that second parent lands on the reply's genesis commit — a
-/// cryptographic happens-after proof that the reply was written against
-/// exactly that revision of the parent, on top of the reachability edge it
-/// gives the parent against gc.
-///
-/// ## Requirements
-///
-/// @relation(comments.ref, comments.authorship, anchor.reachability)
-pub fn store(
- repo: &Path,
- id: &str,
- comment: &Comment,
- author: (&str, &str),
-) -> Result<(), git_store::Error> {
- let context = git_anchor::context(repo, &comment.anchor)
- .map_err(|error| git_store::Error::Invalid(error.to_string()))?;
- let odb = odb_at(repo)?;
- let retained = embed(&odb, &comment.anchor, &context)?;
- let stored = StoredComment {
- body: comment.body.clone(),
- anchor: comment.anchor.clone(),
- issue: comment.issue.clone(),
- reply_to: comment.reply_to.clone(),
- retained,
- };
- let store = git_store::Store::open(repo)?;
- let extra_parents = match &comment.reply_to {
- Some(parent_id) => {
- let parent_ref = format!("{COMMENTS_NS}/{parent_id}");
- let tip = store.ref_commit(&parent_ref)?.ok_or_else(|| {
- git_store::Error::Invalid(format!("reply_to comment {parent_id} does not exist"))
- })?;
- vec![tip]
- }
- None => Vec::new(),
- };
- store.store_item_authored_with_parents(
- COMMENTS_NS,
- id,
- &stored,
- "Update comment",
- author,
- &extra_parents,
- )
-}
-
-/// Write the anchored blob (by its existing object id, no copy) and a fresh
-/// `context` blob into a small tree, wrapped as a [`RawTree`] ready to embed
-/// in a [`StoredComment`] — the retention mechanism [`store`] relies on.
-///
-/// ## Requirements
-///
-/// @relation(anchor.reachability)
-fn embed(
- odb: &gix::odb::Handle,
- anchor: &Anchor,
- context: &str,
-) -> Result<RawTree, git_store::Error> {
- let blob_oid = ObjectId::try_from(&anchor.blob)
- .map_err(|error| git_store::Error::Invalid(error.to_string()))?;
- let context_oid = odb
- .write(&Blob {
- data: context.as_bytes().to_vec(),
- })
- .map_err(|error| git_store::Error::Object(error.to_string()))?;
- let mut entries = vec![
- TreeEntry {
- mode: EntryMode::from(EntryKind::Blob),
- filename: "blob".into(),
- oid: blob_oid,
- },
- TreeEntry {
- mode: EntryMode::from(EntryKind::Blob),
- filename: "context".into(),
- oid: context_oid,
- },
- ];
- entries.sort();
- let tree_oid = odb
- .write(&Tree { entries })
- .map_err(|error| git_store::Error::Object(error.to_string()))?;
- Ok(RawTree::new(tree_oid))
-}
-
-/// Open a raw object database on `repo`'s common git directory, the same one
-/// [`git_store::Store`] uses internally — opened again here since writing the
-/// retained blob and context tree directly is this crate's own concern (see
-/// [`embed`]), the same reasoning `git-toolchain` documents for its own
-/// direct object writes.
-fn odb_at(repo: &Path) -> Result<gix::odb::Handle, git_store::Error> {
- let opened = gix::open(repo).map_err(|error| git_store::Error::Open(Box::new(error)))?;
- gix::odb::at(opened.common_dir().join("objects")).map_err(|_io| git_store::Error::Odb)
-}
-
-/// List every comment in `repo` as `(id, comment)` pairs, newest ref first.
-pub fn list(repo: &Path) -> Result<Vec<(String, Comment)>, git_store::Error> {
- Ok(git_store::Store::open(repo)?
- .list_items::<StoredComment>(COMMENTS_NS)?
- .into_iter()
- .map(|(id, stored)| (id, stored.into()))
- .collect())
-}
-
-/// Who created and who last updated the comment at `id`, recovered from its
-/// ref's commit chain, or `None` when no such comment exists.
-///
-/// ## Requirements
-///
-/// @relation(comments.authorship)
-pub fn provenance(repo: &Path, id: &str) -> Result<Option<Provenance>, git_store::Error> {
- git_store::Store::open(repo)?.item_provenance(COMMENTS_NS, id)
-}
-
-/// Where the comment `id`'s anchor sits on `target` (a revision in `repo`):
-/// still [`Projection::Current`], relocated to a new path or shifted lines,
-/// outdated because the anchored region was edited, or gone with its file.
-///
-/// Tries [`git_anchor::project`] first; if the comment's anchored commit has
-/// been garbage collected, falls back to [`git_anchor::project_from_context`]
-/// against the comment's retained `context` blob — recomputing the context
-/// would need the very commit that is gone.
-///
-/// ## Requirements
-///
-/// @relation(comments.projection)
-pub fn project(repo: &Path, id: &str, target: &str) -> Result<Projection, git_anchor::Error> {
- let stored: StoredComment = git_store::Store::open(repo)
- .and_then(|store| store.load_item(COMMENTS_NS, id))
- .map_err(|error| git_anchor::Error::Object(error.to_string()))?
- .ok_or_else(|| git_anchor::Error::Object(format!("{COMMENTS_NS}/{id} does not exist")))?;
- match git_anchor::project(repo, &stored.anchor, target) {
- Err(git_anchor::Error::AnchorCommitMissing(_)) => {
- let context = retained_context(repo, &stored)
- .map_err(|error| git_anchor::Error::Object(error.to_string()))?;
- git_anchor::project_from_context(repo, &stored.anchor, target, &context)
- }
- other => other,
- }
-}
-
-/// Read the `context` blob out of `stored`'s retained tree (see
-/// [`StoredComment`]), for [`project`]'s fallback path.
-fn retained_context(repo: &Path, stored: &StoredComment) -> Result<String, git_store::Error> {
- let odb = odb_at(repo)?;
- let mut tree_buf = Vec::new();
- let tree = odb
- .find_tree(&stored.retained.oid(), &mut tree_buf)
- .map_err(|error| git_store::Error::Object(error.to_string()))?;
- let entry = tree
- .entries
- .iter()
- .find(|entry| entry.filename == "context")
- .ok_or_else(|| git_store::Error::Object("retained tree has no context entry".to_owned()))?;
- let mut blob_buf = Vec::new();
- let blob = odb
- .find_blob(entry.oid, &mut blob_buf)
- .map_err(|error| git_store::Error::Object(error.to_string()))?;
- Ok(String::from_utf8_lossy(blob.data).into_owned())
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use std::path::Path;
- use std::process::Command;
-
- use git_anchor::LineRange;
- use git_store::test_support::{commit_all, git_with_stdin, repo};
-
- use super::*;
-
- /// A comment whose anchor points at a real (if otherwise arbitrary) blob
- /// in `dir` — `store` now has to read that blob to derive `context`, so a
- /// fixture anchored to a made-up oid would fail before ever reaching the
- /// assertions these tests care about. The anchor's `commit` stays a
- /// made-up hex string: nothing reads it back except as an opaque field.
- fn comment(dir: &Path, body: &str, issue: Option<&str>) -> Comment {
- let blob = git_with_stdin(
- dir,
- &["hash-object", "-w", "--stdin"],
- "one\ntwo\nthree\nfour\n",
- );
- Comment {
- body: body.to_owned(),
- anchor: Anchor {
- commit: "0123456789abcdef0123456789abcdef01234567".into(),
- path: "src/lib.rs".to_owned(),
- blob: blob.as_str().into(),
- lines: Some(LineRange { start: 3, end: 4 }),
- },
- issue: issue.map(str::to_owned),
- reply_to: None,
- }
- }
-
- const AUTHOR: (&str, &str) = ("alice", "alice@example.com");
-
- // @relation(comments.ref, role=Verifies)
- #[test]
- fn store_then_load_round_trips_a_comment() {
- let dir = repo();
- let written = comment(dir.path(), "Why is this 1?", Some("deadbeef"));
- store(dir.path(), "1", &written, AUTHOR).unwrap();
- assert_eq!(load(dir.path(), "1").unwrap(), Some(written));
- }
-
- /// The object id `refname` currently resolves to.
- fn rev_parse(dir: &Path, refname: &str) -> String {
- let output = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["rev-parse", refname])
- .output()
- .unwrap();
- assert!(output.status.success());
- String::from_utf8(output.stdout).unwrap().trim().to_owned()
- }
-
- /// `commit`'s parent object ids, in order, read straight off the commit
- /// object rather than through [`git_store`] — the DAG shape is exactly
- /// what this test is checking, so it must not go through the code under
- /// test to observe it.
- fn commit_parents(dir: &Path, commit: &str) -> Vec<String> {
- let output = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["log", "-1", "--pretty=%P", commit])
- .output()
- .unwrap();
- assert!(output.status.success());
- String::from_utf8(output.stdout)
- .unwrap()
- .split_whitespace()
- .map(str::to_owned)
- .collect()
- }
-
- // @relation(comments.ref, role=Verifies)
- #[test]
- fn a_reply_carries_the_parent_comments_tip_as_a_second_parent_and_round_trips_reply_to() {
- let dir = repo();
- let repo_path = dir.path();
-
- let parent = comment(repo_path, "parent comment", None);
- let parent_id = new_id(None, &parent).unwrap();
- store(repo_path, &parent_id, &parent, AUTHOR).unwrap();
- let parent_tip = rev_parse(repo_path, &format!("{COMMENTS_NS}/{parent_id}"));
-
- let mut reply = comment(repo_path, "a reply", None);
- reply.reply_to = Some(parent_id.clone());
- let reply_id = new_id(None, &reply).unwrap();
- store(repo_path, &reply_id, &reply, AUTHOR).unwrap();
-
- assert_eq!(
- load(repo_path, &reply_id).unwrap().unwrap().reply_to,
- Some(parent_id)
- );
-
- // The reply's ref is brand new, so its genesis commit has no prior
- // tip of its own to chain from — the parent comment's tip, carried
- // via `extra_parents`, is its only parent.
- let reply_tip = rev_parse(repo_path, &format!("{COMMENTS_NS}/{reply_id}"));
- let parents = commit_parents(repo_path, &reply_tip);
- assert_eq!(parents, vec![parent_tip]);
- }
-
- #[test]
- fn none_when_the_comment_is_absent() {
- let dir = repo();
- assert_eq!(load(dir.path(), "1").unwrap(), None);
- assert_eq!(provenance(dir.path(), "1").unwrap(), None);
- }
-
- #[test]
- fn lists_comments_keyed_by_id() {
- let dir = repo();
- store(dir.path(), "1", &comment(dir.path(), "first", None), AUTHOR).unwrap();
- store(
- dir.path(),
- "2",
- &comment(dir.path(), "second", None),
- AUTHOR,
- )
- .unwrap();
- let mut ids: Vec<String> = list(dir.path())
- .unwrap()
- .into_iter()
- .map(|(id, _)| id)
- .collect();
- ids.sort();
- assert_eq!(ids, vec!["1".to_owned(), "2".to_owned()]);
- }
-
- #[test]
- fn new_id_uses_the_origin_when_one_is_given() {
- let dir = repo();
- let content = comment(dir.path(), "a comment", None);
- assert_eq!(new_id(Some("deadbeef"), &content).unwrap(), "deadbeef");
- }
-
- #[test]
- fn new_id_hashes_its_own_content_with_no_origin() {
- let dir = repo();
- let a = comment(dir.path(), "a comment", None);
- let b = comment(dir.path(), "a different comment", None);
- let a_id = new_id(None, &a).unwrap();
- assert_eq!(a_id, new_id(None, &a).unwrap());
- assert_ne!(a_id, new_id(None, &b).unwrap());
- }
-
- // @relation(comments.authorship, role=Verifies)
- #[test]
- fn provenance_comes_from_the_commits_not_the_document() {
- let dir = repo();
- store(dir.path(), "1", &comment(dir.path(), "first", None), AUTHOR).unwrap();
- store(
- dir.path(),
- "1",
- &comment(dir.path(), "edited", None),
- ("bob", "bob@example.com"),
- )
- .unwrap();
- let provenance = provenance(dir.path(), "1").unwrap().unwrap();
- assert_eq!(provenance.created.name, "alice");
- assert_eq!(provenance.created.email, "alice@example.com");
- assert_eq!(provenance.updated.name, "bob");
- assert!(provenance.created.seconds > 0);
- }
-
- /// The current branch's short name, so a test that force-moves the
- /// branch ref does not have to guess `init.defaultBranch`.
- fn current_branch(dir: &Path) -> String {
- let output = Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["symbolic-ref", "--short", "HEAD"])
- .output()
- .unwrap();
- assert!(output.status.success());
- String::from_utf8(output.stdout).unwrap().trim().to_owned()
- }
-
- /// Whether `oid` still exists as an object in `dir`'s repository.
- fn object_exists(dir: &Path, oid: &str) -> bool {
- Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(["cat-file", "-e", oid])
- .status()
- .unwrap()
- .success()
- }
-
- // @relation(anchor.reachability, role=Verifies)
- #[test]
- fn the_anchored_blob_survives_branch_deletion_and_gc_pruning_the_anchor_commit() {
- let dir = repo();
- let repo_path = dir.path();
- std::fs::write(repo_path.join("file.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
- commit_all(repo_path, "one");
-
- let anchor = git_anchor::capture(
- repo_path,
- "HEAD",
- "file.txt",
- Some(LineRange { start: 2, end: 2 }),
- )
- .unwrap();
- let anchor_commit = anchor.commit.to_string();
- let written = Comment {
- body: "why two?".to_owned(),
- anchor,
- issue: None,
- reply_to: None,
- };
- let id = new_id(None, &written).unwrap();
- store(repo_path, &id, &written, AUTHOR).unwrap();
-
- // Rewrite the branch onto a brand-new parentless commit holding an
- // edited file, so the original commit is no longer anyone's
- // ancestor. An ordinary edit could never detach history like this,
- // but a rebase, a `filter-repo` pass, or a force-push can, and that
- // is exactly the scenario retention has to survive.
- let edited_blob = git_with_stdin(
- repo_path,
- &["hash-object", "-w", "--stdin"],
- "zero\none\ntwo\nthree\nfour\nfive\n",
- );
- let edited_tree = git_with_stdin(
- repo_path,
- &["mktree"],
- &format!("100644 blob {edited_blob}\tfile.txt\n"),
- );
- let replacement =
- git_with_stdin(repo_path, &["commit-tree", &edited_tree, "-m", "two"], "");
- let branch = current_branch(repo_path);
- let status = Command::new("git")
- .arg("-C")
- .arg(repo_path)
- .args(["update-ref", &format!("refs/heads/{branch}"), &replacement])
- .status()
- .unwrap();
- assert!(status.success());
- assert!(
- Command::new("git")
- .arg("-C")
- .arg(repo_path)
- .args(["reflog", "expire", "--expire=now", "--all"])
- .status()
- .unwrap()
- .success()
- );
- assert!(
- Command::new("git")
- .arg("-C")
- .arg(repo_path)
- .args(["gc", "--prune=now", "--quiet"])
- .status()
- .unwrap()
- .success()
- );
-
- assert!(
- !object_exists(repo_path, &anchor_commit),
- "the anchor commit should have been pruned"
- );
-
- // The anchored blob, embedded in the comment's own tree, is still
- // readable straight off the ref...
- let loaded = load(repo_path, &id).unwrap().unwrap();
- assert_eq!(
- git_anchor::snippet(repo_path, &loaded.anchor).unwrap(),
- "two\n"
- );
- // ...and still projects onto the rewritten branch tip, via the
- // context fallback `project` reaches for once the anchor commit is
- // gone.
- assert_eq!(
- project(repo_path, &id, &replacement).unwrap(),
- Projection::Relocated {
- path: "file.txt".to_owned(),
- lines: Some(LineRange { start: 3, end: 3 }),
- }
- );
- }
-
- // @relation(comments.anchor, comments.projection, role=Verifies)
- #[test]
- fn a_stored_comment_projects_onto_the_commit_it_was_written_against() {
- let dir = repo();
- std::fs::write(dir.path().join("file.txt"), "one\ntwo\nthree\n").unwrap();
- commit_all(dir.path(), "one");
-
- let anchor = git_anchor::capture(
- dir.path(),
- "HEAD",
- "file.txt",
- Some(LineRange { start: 2, end: 2 }),
- )
- .unwrap();
- let written = Comment {
- body: "Why two?".to_owned(),
- anchor,
- issue: None,
- reply_to: None,
- };
- let id = new_id(None, &written).unwrap();
- store(dir.path(), &id, &written, AUTHOR).unwrap();
-
- let loaded = load(dir.path(), &id).unwrap().unwrap();
- assert_eq!(
- git_anchor::snippet(dir.path(), &loaded.anchor).unwrap(),
- "two\n"
- );
- assert_eq!(
- project(dir.path(), &id, "HEAD").unwrap(),
- Projection::Current
- );
- }
-
- // @relation(comments.anchor, storage.meta-ref, role=Verifies)
- #[test]
- fn loads_the_on_disk_comment_format() {
- // A fixture written as the real on-disk layout — a `body` blob, an
- // `anchor/` subtree of `commit`/`path`/`blob` blobs with a
- // `lines/some/{start,end}` Option subtree, an `issue/some` Option
- // blob, an empty `reply_to` Option tree (`None`), and a
- // `retained/{blob,context}` passthrough tree — must keep loading,
- // guarding the document's shape against an incompatible change to
- // data already on a ref.
- let dir = repo();
- let repo = dir.path();
- let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value);
-
- let expected = comment(repo, "Why is this 1?", Some("deadbeef"));
- let range = expected.anchor.lines.unwrap();
- let range_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {}\tstart\n100644 blob {}\tend\n",
- blob(&range.start.to_string()),
- blob(&range.end.to_string()),
- ),
- );
- let lines_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {range_tree}\tsome\n"),
- );
- let anchor_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {}\tcommit\n\
- 100644 blob {}\tpath\n\
- 100644 blob {}\tblob\n\
- 040000 tree {lines_tree}\tlines\n",
- blob(&expected.anchor.commit.to_string()),
- blob(&expected.anchor.path),
- blob(&expected.anchor.blob.to_string()),
- ),
- );
- let issue_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {}\tsome\n",
- blob(expected.issue.as_deref().unwrap())
- ),
- );
- let retained_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {}\tblob\n100644 blob {}\tcontext\n",
- expected.anchor.blob,
- blob("one\ntwo\nthree\nfour\n"),
- ),
- );
- let reply_to_tree = git_with_stdin(repo, &["mktree"], "");
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {}\tbody\n\
- 040000 tree {anchor_tree}\tanchor\n\
- 040000 tree {issue_tree}\tissue\n\
- 040000 tree {reply_to_tree}\treply_to\n\
- 040000 tree {retained_tree}\tretained\n",
- blob(&expected.body),
- ),
- );
- let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", &format!("{COMMENTS_NS}/1"), &commit])
- .status()
- .unwrap();
- assert!(status.success());
-
- assert_eq!(load(repo, "1").unwrap(), Some(expected));
- }
-}
crates/git-effect/Cargo.toml
@@ -1,23 +1,0 @@
-[package]
-name = "git-effect"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-facet = { workspace = true }
-git-backend = { workspace = true }
-git-store = { workspace = true }
-git-toolchain = { workspace = true }
-gix-hash = { workspace = true }
-portable-pty = "0.9.0"
-tempfile = { workspace = true }
-tokio = { workspace = true }
-uuid = { workspace = true }
-
-[dev-dependencies]
-git-store = { workspace = true, features = ["test-support"] }
-
-[lints]
-workspace = true
crates/git-effect/src/cache.rs
@@ -1,228 +1,0 @@
-//! A read-write cache directory persisted at `refs/meta/cache/<name>`,
-//! restored into the sandbox before an effect that names it runs and
-//! snapshotted back after — unlike a toolchain (`git-toolchain`, extract-once
-//! and immutable), a cache's contents change on every run, so it is written
-//! back rather than only ever read.
-//!
-//! The persisted snapshot survives independent of the Sprite's own lifetime:
-//! a Sprite reset or migration loses nothing a cache-using effect built up,
-//! since the cache lives in the object database under [`CACHE_NS`] like
-//! everything else `git-store` holds — not just on the Sprite's own
-//! persistent filesystem, which the toolchain extraction cache leans on
-//! instead.
-
-use std::path::Path;
-use std::process::Command;
-
-use gix_hash::ObjectId;
-
-/// The ref namespace holding cache snapshots, one ref per cache:
-/// `refs/meta/cache/<name>`.
-pub const CACHE_NS: &str = "refs/meta/cache";
-
-/// The ref holding the cache named `name`.
-#[must_use]
-pub fn cache_ref(name: &str) -> String {
- format!("{CACHE_NS}/{name}")
-}
-
-/// Where cache `name` is restored inside the sandbox, exported to an
-/// effect's command as `$EFFECT_CACHE_DIR`.
-#[must_use]
-pub fn cache_dir(name: &str) -> String {
- format!("/cache/{name}")
-}
-
-/// Restore `name`'s persisted snapshot (if any) into the sandbox at
-/// [`cache_dir`], so an effect using it picks up where the last run against
-/// this cache left off. The directory is created even when there is no prior
-/// snapshot, so the tool populating it (sccache, ...) always finds it there
-/// on a cold start.
-///
-/// ## Requirements
-///
-/// @relation(checks.cache)
-pub fn restore(repo: &Path, sprite: &str, name: &str) -> Result<(), String> {
- let dir = cache_dir(name);
- let mkdir = Command::new("sprite")
- .args([
- "exec",
- "-s",
- sprite,
- "--",
- "sh",
- "-c",
- &format!("mkdir -p {dir}"),
- ])
- .status()
- .map_err(|e| format!("could not run the sprite CLI: {e}"))?;
- if !mkdir.success() {
- return Err(format!(
- "could not create cache directory {dir} in the sprite"
- ));
- }
-
- let store = git_store::Store::open(repo).map_err(|e| format!("could not open store: {e}"))?;
- let Ok(tree) = store.ref_tree(&cache_ref(name)) else {
- // No snapshot yet — a fresh cache, populated by whatever the command runs.
- return Ok(());
- };
-
- let mut archive = Command::new("git");
- archive
- .arg("-C")
- .arg(repo)
- .args(["archive", "--format=tar", &tree.to_string()]);
- let mut unpack = Command::new("sprite");
- unpack.args([
- "exec",
- "-s",
- sprite,
- "--",
- "sh",
- "-c",
- &format!("tar -x -C {dir}"),
- ]);
- crate::stream::pipe(archive, unpack, &format!("restoring cache {name}"))
-}
-
-/// Snapshot the sandbox's [`cache_dir`] for `name` back to [`cache_ref`],
-/// replacing any prior snapshot with a parentless commit — cache history has
-/// no audit value, so replaced snapshots become garbage-collectable instead
-/// of pinned by a parent chain. The tip is the state [`restore`] will pick
-/// up on this cache's next use.
-///
-/// ## Requirements
-///
-/// @relation(checks.cache)
-pub fn snapshot(repo: &Path, sprite: &str, name: &str) -> Result<(), String> {
- let dir = cache_dir(name);
- let scratch = tempfile::tempdir().map_err(|e| format!("could not create temp dir: {e}"))?;
- let extracted = scratch.path().join("tree");
- std::fs::create_dir(&extracted).map_err(|e| format!("could not create extraction dir: {e}"))?;
- let mut archive = Command::new("sprite");
- archive.args([
- "exec",
- "-s",
- sprite,
- "--",
- "sh",
- "-c",
- &format!("tar -C {dir} -cf - ."),
- ]);
- let mut extract = Command::new("tar");
- extract.args(["-x", "-C"]).arg(&extracted);
- crate::stream::pipe(archive, extract, &format!("snapshotting cache {name}"))?;
-
- // A scratch index and an explicit work tree, so this builds a tree from
- // the extracted directory without disturbing the repository's own
- // (nonexistent, since it is bare) index. The index lives as a sibling of
- // `extracted`, never inside it — otherwise `git add -A .` stages the
- // index/lock files themselves, and a snapshot taken that way poisons every
- // future run: restoring it into the sandbox and archiving it back places
- // a stale `.git-index.lock` inside the next extraction, which then
- // collides with the real lock `git add` tries to create there.
- let index = scratch.path().join(".git-index");
- let add = Command::new("git")
- .arg("-C")
- .arg(repo)
- .env("GIT_INDEX_FILE", &index)
- .env("GIT_WORK_TREE", &extracted)
- .args(["add", "-A", "."])
- .status()
- .map_err(|e| format!("could not stage the cache tree: {e}"))?;
- if !add.success() {
- return Err(format!("could not stage cache {name}'s tree"));
- }
- let write_tree = Command::new("git")
- .arg("-C")
- .arg(repo)
- .env("GIT_INDEX_FILE", &index)
- .env("GIT_WORK_TREE", &extracted)
- .args(["write-tree"])
- .output()
- .map_err(|e| format!("could not write the cache tree: {e}"))?;
- if !write_tree.status.success() {
- return Err(format!("could not write cache {name}'s tree"));
- }
- let tree = String::from_utf8_lossy(&write_tree.stdout);
- let tree = ObjectId::from_hex(tree.trim().as_bytes())
- .map_err(|e| format!("git write-tree returned an invalid tree oid: {e}"))?;
-
- let store = git_store::Store::open(repo).map_err(|e| format!("could not open store: {e}"))?;
- store
- .store_tree_replace(&cache_ref(name), tree, "Update cache")
- .map_err(|e| format!("could not store cache {name}: {e}"))
-}
-
-/// [`restore`]'s local-backend equivalent: `dest` is already a host directory
-/// (a [`crate::local::Sandbox`] bind-mounted straight into a Docker
-/// container, or used as-is for host-direct execution), so restoring is just
-/// extracting the snapshot tree onto it — no sandbox CLI transport needed.
-///
-/// ## Requirements
-///
-/// @relation(checks.cache)
-pub fn restore_local(repo: &Path, dest: &Path, name: &str) -> Result<(), String> {
- std::fs::create_dir_all(dest).map_err(|e| format!("could not create cache directory: {e}"))?;
-
- let store = git_store::Store::open(repo).map_err(|e| format!("could not open store: {e}"))?;
- let Ok(tree) = store.ref_tree(&cache_ref(name)) else {
- return Ok(());
- };
-
- let mut archive = Command::new("git");
- archive
- .arg("-C")
- .arg(repo)
- .args(["archive", "--format=tar", &tree.to_string()]);
- let mut extract = Command::new("tar");
- extract.args(["-x", "-C"]).arg(dest);
- crate::stream::pipe(archive, extract, &format!("restoring cache {name}"))
-}
-
-/// [`snapshot`]'s local-backend equivalent: `src` is already a host
-/// directory, so snapshotting is building a tree from it directly, without
-/// first archiving it out of a sandbox. Uses a scratch index alongside `src`
-/// (never inside it) for the same reason [`snapshot`] does: a `.git-index`
-/// left inside `src` would poison the next run's restore/snapshot cycle.
-///
-/// ## Requirements
-///
-/// @relation(checks.cache)
-pub fn snapshot_local(repo: &Path, src: &Path, name: &str) -> Result<(), String> {
- let index_dir =
- tempfile::tempdir().map_err(|e| format!("could not create scratch dir: {e}"))?;
- let index = index_dir.path().join(".git-index");
-
- let add = Command::new("git")
- .arg("-C")
- .arg(repo)
- .env("GIT_INDEX_FILE", &index)
- .env("GIT_WORK_TREE", src)
- .args(["add", "-A", "."])
- .status()
- .map_err(|e| format!("could not stage cache {name}'s tree: {e}"))?;
- if !add.success() {
- return Err(format!("could not stage cache {name}'s tree"));
- }
- let write_tree = Command::new("git")
- .arg("-C")
- .arg(repo)
- .env("GIT_INDEX_FILE", &index)
- .env("GIT_WORK_TREE", src)
- .args(["write-tree"])
- .output()
- .map_err(|e| format!("could not write cache {name}'s tree: {e}"))?;
- if !write_tree.status.success() {
- return Err(format!("could not write cache {name}'s tree"));
- }
- let tree = String::from_utf8_lossy(&write_tree.stdout);
- let tree = ObjectId::from_hex(tree.trim().as_bytes())
- .map_err(|e| format!("git write-tree returned an invalid tree oid: {e}"))?;
-
- let store = git_store::Store::open(repo).map_err(|e| format!("could not open store: {e}"))?;
- store
- .store_tree_replace(&cache_ref(name), tree, "Update cache")
- .map_err(|e| format!("could not store cache {name}: {e}"))
-}
crates/git-effect/src/definition.rs
@@ -1,500 +1,0 @@
-//! The configured effects, sourced from `refs/meta/effects/<name>` — one ref
-//! per effect.
-//!
-//! An effect is anything a server runs against a push — CI, CD, linting,
-//! versioning gates, and so on. Decomposed one ref per effect (rather than a
-//! single aggregated map, as the prior "checks" naming used) so an effect can
-//! be added or removed as an independent, separately-history'd ref, and so the
-//! admin-only write rule can be stated as a single refname glob
-//! (`refs/meta/effects/*`) instead of gating one shared ref. The document is
-//! read and written through [`git_store`], so an effect 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 effects that gate it.
-//!
-//! # Migration note
-//!
-//! Effects were checks: `refs/meta/checks` (one ref, a scalar-keyed map of
-//! `checks/<name>` subtrees) decomposed to `refs/meta/effects/<name>` (one ref
-//! per effect), and `Check`/`CheckBody` renamed to [`Effect`]/`EffectBody`.
-//! Incompatible with data written in the prior layout — acceptable pre-1.0
-//! (see the format compatibility rules in `git_store`'s module docs).
-
-use std::path::Path;
-
-use facet::Facet;
-
-use git_store::component;
-
-/// The ref namespace holding the configured effects, one
-/// `refs/meta/effects/<name>` ref per effect.
-pub const EFFECTS_NS: &str = "refs/meta/effects";
-
-/// The ref holding the effect named `name`.
-#[must_use]
-pub fn effect_ref(name: &str) -> String {
- format!("{EFFECTS_NS}/{name}")
-}
-
-/// A configured effect's on-disk body. The ref's last segment (its name) is
-/// the effect's identity, so it is not duplicated inside the body.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct EffectBody {
- /// The shell command run for the effect (e.g. `cargo fmt --check`), or
- /// `None` for a composite effect that only aggregates its `depends`.
- command: Option<String>,
- /// The sandbox image the command runs in; `None` uses the default.
- image: Option<String>,
- /// Names of sibling effects that must pass before this one runs. Stored
- /// as `None` when empty so an independent effect stays a minimal tree.
- depends: Option<Vec<String>>,
- /// Names of toolchains (`git-toolchain`, `refs/meta/toolchains/<name>`)
- /// activated on `PATH` before the command runs. Stored as `None` when
- /// empty, like `depends`.
- toolchains: Option<Vec<String>>,
- /// Name of a persisted cache (`refs/meta/cache/<name>`) restored into the
- /// sandbox before the command runs and snapshotted back after, or `None`
- /// for an effect with no cache.
- cache: Option<String>,
-}
-
-impl component::Collection for EffectBody {
- const NS: &'static str = EFFECTS_NS;
-}
-
-/// One configured effect, assembled from its ref name and [`EffectBody`] at
-/// load.
-///
-/// ## Requirements
-///
-/// @relation(checks.definition)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Effect {
- /// The name it is stored under (`refs/meta/effects/<name>`).
- pub name: String,
- /// The shell command run for the effect (e.g. `cargo fmt --check`), or
- /// `None` for a composite effect that only aggregates its dependencies.
- pub command: Option<String>,
- /// The sandbox image the command runs in; `None` uses the default.
- pub image: Option<String>,
- /// Names of sibling effects that must pass before this one runs.
- pub depends: Vec<String>,
- /// Names of toolchains activated on `PATH` before the command runs.
- pub toolchains: Vec<String>,
- /// Name of a persisted cache (`refs/meta/cache/<name>`) restored into the
- /// sandbox before the command runs and snapshotted back after.
- pub cache: Option<String>,
-}
-
-impl component::Component for Effect {
- const NOUN: &'static str = "effect";
- const PLURAL: &'static str = "effects";
-}
-
-fn compose(name: String, body: EffectBody) -> Effect {
- Effect {
- name,
- command: body.command,
- image: body.image,
- depends: body.depends.unwrap_or_default(),
- toolchains: body.toolchains.unwrap_or_default(),
- cache: body.cache,
- }
-}
-
-fn decompose(effect: &Effect) -> EffectBody {
- EffectBody {
- command: effect.command.clone(),
- image: effect.image.clone(),
- depends: if effect.depends.is_empty() {
- None
- } else {
- Some(effect.depends.clone())
- },
- toolchains: if effect.toolchains.is_empty() {
- None
- } else {
- Some(effect.toolchains.clone())
- },
- cache: effect.cache.clone(),
- }
-}
-
-/// Load the effect named `name` at [`effect_ref`] in `repo`, or `None` when
-/// it is not configured.
-pub fn load(repo: &Path, name: &str) -> Result<Option<Effect>, git_store::Error> {
- let store = git_store::Store::open(repo)?;
- Ok(
- component::load_item::<EffectBody>(&store, name)?
- .map(|body| compose(name.to_owned(), body)),
- )
-}
-
-/// Load every configured effect in `repo`. An absent [`EFFECTS_NS`] yields an
-/// empty set, as on a server whose effects have not been pushed yet.
-pub fn load_all(repo: &Path) -> Result<Vec<Effect>, git_store::Error> {
- let store = git_store::Store::open(repo)?;
- Ok(component::list::<EffectBody>(&store)?
- .into_iter()
- .map(|(name, body)| compose(name, body))
- .collect())
-}
-
-/// Write `effect` to its own [`effect_ref`] in `repo`, replacing any existing
-/// value as a new commit.
-pub fn store(repo: &Path, effect: &Effect) -> Result<(), git_store::Error> {
- let store = git_store::Store::open(repo)?;
- component::store_item::<EffectBody>(&store, &effect.name, &decompose(effect), "Update effect")
-}
-
-/// Validate `effects` as a static dependency graph and return them in an
-/// order that runs every effect after its dependencies — Kahn's topological
-/// sort, with ties broken by name so the order is deterministic.
-///
-/// Rejected here, at write time, so the worker only ever walks a fixed order:
-/// a `depends` entry naming no configured effect, a duplicate or self edge, an
-/// effect with neither a command nor dependencies, any dependency cycle
-/// (reported with its member names), and a `toolchains` entry that is not a
-/// valid ref-path segment. An effect that sets an `image` is also rejected
-/// until the Sprite sandbox can honor one — the field exists in the format
-/// now so supporting it later is not a data migration. Whether a named
-/// toolchain actually exists is checked server-side at job time, not here —
-/// unlike `depends`, `toolchains` cross-references a different ref
-/// namespace this function has no set of configured names to check against. A
-/// `cache` naming an invalid ref-path segment is rejected the same way.
-///
-/// ## Requirements
-///
-/// @relation(checks.definition, checks.toolchains)
-pub fn order(effects: &[Effect]) -> Result<Vec<&Effect>, String> {
- let mut by_name: std::collections::BTreeMap<&str, &Effect> = std::collections::BTreeMap::new();
- for effect in effects {
- if by_name.insert(effect.name.as_str(), effect).is_some() {
- return Err(format!("effect {} is defined twice", effect.name));
- }
- }
- let mut blocking: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
- for effect in effects {
- if effect.command.is_none() && effect.depends.is_empty() {
- return Err(format!(
- "effect {} has neither a command nor dependencies",
- effect.name
- ));
- }
- if effect.image.is_some() {
- return Err(format!(
- "effect {} sets an image, which the effects sandbox does not support yet",
- effect.name
- ));
- }
- for toolchain in &effect.toolchains {
- if !git_store::ref_segment_ok(toolchain) {
- return Err(format!(
- "effect {} names an invalid toolchain {toolchain:?}",
- effect.name
- ));
- }
- }
- if let Some(cache) = &effect.cache
- && !git_store::ref_segment_ok(cache)
- {
- return Err(format!(
- "effect {} names an invalid cache {cache:?}",
- effect.name
- ));
- }
- let mut seen = std::collections::BTreeSet::new();
- for dep in &effect.depends {
- if !by_name.contains_key(dep.as_str()) {
- return Err(format!(
- "effect {} depends on unknown effect {dep}",
- effect.name
- ));
- }
- if dep == &effect.name {
- return Err(format!("effect {} depends on itself", effect.name));
- }
- if !seen.insert(dep.as_str()) {
- return Err(format!(
- "effect {} lists dependency {dep} twice",
- effect.name
- ));
- }
- }
- blocking.insert(effect.name.as_str(), effect.depends.len());
- }
-
- let mut ordered = Vec::with_capacity(effects.len());
- while ordered.len() < effects.len() {
- let ready: Vec<&str> = blocking
- .iter()
- .filter_map(|(name, blockers)| (*blockers == 0).then_some(*name))
- .collect();
- if ready.is_empty() {
- let cycle: Vec<&str> = blocking.keys().copied().collect();
- return Err(format!(
- "effect dependencies form a cycle: {}",
- cycle.join(", ")
- ));
- }
- for name in ready {
- let _ready = blocking.remove(name);
- if let Some(effect) = by_name.get(name) {
- ordered.push(*effect);
- }
- for (blocked, blockers) in blocking.iter_mut() {
- if let Some(effect) = by_name.get(blocked)
- && effect.depends.iter().any(|dep| dep == name)
- {
- *blockers = blockers.saturating_sub(1);
- }
- }
- }
- }
- Ok(ordered)
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::indexing_slicing,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_effect_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("effect")
- }
-
- fn effect(name: &str, command: &str) -> Effect {
- Effect {
- name: name.to_owned(),
- command: Some(command.to_owned()),
- image: None,
- depends: Vec::new(),
- toolchains: Vec::new(),
- cache: None,
- }
- }
-
- fn composite(name: &str, depends: &[&str]) -> Effect {
- Effect {
- name: name.to_owned(),
- command: None,
- image: None,
- depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
- toolchains: Vec::new(),
- cache: None,
- }
- }
-
- fn dependent(name: &str, command: &str, depends: &[&str]) -> Effect {
- Effect {
- depends: depends.iter().map(|dep| (*dep).to_owned()).collect(),
- ..effect(name, command)
- }
- }
-
- fn toolchained(name: &str, command: &str, toolchains: &[&str]) -> Effect {
- Effect {
- toolchains: toolchains.iter().map(|t| (*t).to_owned()).collect(),
- ..effect(name, command)
- }
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn store_then_load_round_trips_an_effect() {
- let repo = unique_repo();
- let written = effect("fmt", "cargo fmt --check");
- store(&repo, &written).unwrap();
- assert_eq!(load(&repo, "fmt").unwrap(), Some(written));
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn store_then_load_all_round_trips_the_effect_set() {
- let repo = unique_repo();
- let written = vec![
- effect("fmt", "cargo fmt --check"),
- effect("test", "cargo nextest run"),
- ];
- for item in &written {
- store(&repo, item).unwrap();
- }
- let mut loaded = load_all(&repo).unwrap();
- loaded.sort_by(|a, b| a.name.cmp(&b.name));
- assert_eq!(loaded, written);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn empty_when_no_effects_are_configured() {
- let repo = unique_repo();
- assert!(load_all(&repo).unwrap().is_empty());
- assert!(load(&repo, "fmt").unwrap().is_none());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn loads_the_on_disk_effect_format() {
- // A fixture written as the real `command/some` subtree layout (the
- // `Option`-wrapped command, with `image`/`depends`/`toolchains`
- // omitted entirely) must keep loading, with the missing optional
- // fields unset — guarding the effect document's shape against an
- // incompatible change to data already on a ref.
- let repo = unique_repo();
- write_effect_doc(&repo, "fmt", "cargo fmt --check");
- assert_eq!(
- load(&repo, "fmt").unwrap(),
- Some(effect("fmt", "cargo fmt --check"))
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn store_then_load_round_trips_image_and_depends() {
- let repo = unique_repo();
- let written = vec![
- Effect {
- image: Some("rust:1.88".to_owned()),
- ..effect("fmt", "cargo fmt --check")
- },
- dependent("test", "cargo nextest run", &["fmt"]),
- composite("ci", &["fmt", "test"]),
- ];
- for item in &written {
- store(&repo, item).unwrap();
- }
- let mut loaded = load_all(&repo).unwrap();
- loaded.sort_by(|a, b| a.name.cmp(&b.name));
- let mut expected = written;
- expected.sort_by(|a, b| a.name.cmp(&b.name));
- assert_eq!(loaded, expected);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_runs_dependencies_first() {
- let effects = vec![
- composite("ci", &["test", "fmt"]),
- dependent("test", "cargo nextest run", &["fmt"]),
- effect("fmt", "cargo fmt --check"),
- ];
- let names: Vec<&str> = order(&effects)
- .unwrap()
- .iter()
- .map(|c| c.name.as_str())
- .collect();
- assert_eq!(names, vec!["fmt", "test", "ci"]);
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_a_cycle() {
- let effects = vec![
- dependent("a", "true", &["b"]),
- dependent("b", "true", &["a"]),
- effect("fmt", "cargo fmt --check"),
- ];
- let err = order(&effects).unwrap_err();
- assert!(err.contains("cycle"), "unexpected error: {err}");
- assert!(err.contains('a') && err.contains('b'));
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_an_unknown_dependency() {
- let effects = vec![dependent("test", "cargo nextest run", &["fmt"])];
- let err = order(&effects).unwrap_err();
- assert!(
- err.contains("unknown effect fmt"),
- "unexpected error: {err}"
- );
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_self_and_duplicate_edges() {
- let selfish = vec![dependent("a", "true", &["a"])];
- assert!(order(&selfish).unwrap_err().contains("itself"));
- let doubled = vec![
- effect("fmt", "true"),
- dependent("a", "true", &["fmt", "fmt"]),
- ];
- assert!(order(&doubled).unwrap_err().contains("twice"));
- }
-
- // @relation(checks.definition, role=Verifies)
- #[test]
- fn order_rejects_an_empty_effect() {
- let effects = vec![composite("hollow", &[])];
- let err = order(&effects).unwrap_err();
- assert!(
- err.contains("neither a command nor dependencies"),
- "unexpected error: {err}"
- );
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn order_accepts_a_valid_toolchain_name() {
- let effects = vec![toolchained("build", "make", &["gcc-12"])];
- assert_eq!(
- order(&effects)
- .unwrap()
- .iter()
- .map(|c| c.name.as_str())
- .collect::<Vec<_>>(),
- vec!["build"]
- );
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn order_rejects_an_invalid_toolchain_name() {
- let effects = vec![toolchained("build", "make", &["not/valid"])];
- let err = order(&effects).unwrap_err();
- assert!(err.contains("invalid toolchain"), "unexpected error: {err}");
- }
-
- // @relation(checks.toolchains, role=Verifies)
- #[test]
- fn store_then_load_round_trips_toolchains() {
- let repo = unique_repo();
- let written = toolchained("build", "make", &["gcc-12", "cmake"]);
- store(&repo, &written).unwrap();
- assert_eq!(load(&repo, "build").unwrap(), Some(written));
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.cache, role=Verifies)
- #[test]
- fn order_rejects_an_invalid_cache_name() {
- let effects = vec![Effect {
- cache: Some("not/valid".to_owned()),
- ..effect("build", "cargo build")
- }];
- let err = order(&effects).unwrap_err();
- assert!(err.contains("invalid cache"), "unexpected error: {err}");
- }
-
- // @relation(checks.cache, role=Verifies)
- #[test]
- fn store_then_load_round_trips_cache() {
- let repo = unique_repo();
- let written = Effect {
- cache: Some("sccache".to_owned()),
- ..effect("build", "cargo build --workspace")
- };
- store(&repo, &written).unwrap();
- assert_eq!(load(&repo, "build").unwrap(), Some(written));
- let _ = std::fs::remove_dir_all(&repo);
- }
-}
crates/git-effect/src/docker.rs
@@ -1,116 +1,0 @@
-//! Docker sandbox backend for local effect execution: shells out to the
-//! `docker` CLI via `std::process` (no docker API crate — see
-//! [`crate::engine`] for why the Sprite backend does the same with `sprite`),
-//! running each effect in a throwaway `--rm` container with the
-//! [`crate::local::Sandbox`] materialized on the host bind-mounted in. Unlike
-//! the Sprite backend's persistent per-repository sandbox, a container never
-//! outlives its run, so nothing here needs an extract-once cache: toolchains
-//! are re-materialized on the host per run (cheap — it is a local `git
-//! archive`/tree walk, not a network fetch) rather than kept warm across
-//! runs.
-//!
-//! `git effect run` uses this backend by default; `--unsandboxed` skips it
-//! for host-direct execution instead (see [`crate::local`]).
-
-use std::path::Path;
-
-/// The minimal base image every effect runs in — no toolchain of its own;
-/// everything the command needs comes from the bind-mounted, host-exported
-/// toolchains.
-pub const IMAGE: &str = "debian:stable-slim";
-
-/// Where the sandbox's work directory is bind-mounted in the container.
-pub const WORKDIR: &str = "/work";
-
-/// Where the sandbox's toolchains directory is bind-mounted in the
-/// container, read-only — toolchains are extract-once-per-run and never
-/// written to by the command.
-pub const TOOLCHAINS_DIR: &str = "/toolchains";
-
-/// Where the sandbox's cache directory is bind-mounted in the container,
-/// read-write.
-pub const CACHE_DIR: &str = "/cache";
-
-/// Confirm `docker` is on `PATH` and the daemon answers, with a clean error
-/// (rather than a raw "os error 2") when it is not — the one place this
-/// backend can fail before anything else runs.
-pub fn ensure_docker() -> Result<(), String> {
- let status = std::process::Command::new("docker")
- .arg("version")
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .status()
- .map_err(|e| format!("docker is not installed or not on PATH: {e}"))?;
- if status.success() {
- Ok(())
- } else {
- Err("docker is installed but the daemon did not respond (`docker version` failed); is it running?".to_owned())
- }
-}
-
-/// Assemble `docker run`'s argv for one effect's `command` against the
-/// sandbox's host directories — pure, so the exact invocation is unit tested
-/// without a daemon. `command` runs under `sh -c`, stderr folded into stdout
-/// so the captured recording is one interleaved stream, matching what the
-/// Sprite backend's pty capture already gives a developer.
-#[must_use]
-pub fn run_args(work: &Path, toolchains: &Path, cache: &Path, command: &str) -> Vec<String> {
- vec![
- "run".to_owned(),
- "--rm".to_owned(),
- "-v".to_owned(),
- format!("{}:{WORKDIR}", work.display()),
- "-v".to_owned(),
- format!("{}:{TOOLCHAINS_DIR}:ro", toolchains.display()),
- "-v".to_owned(),
- format!("{}:{CACHE_DIR}", cache.display()),
- "-w".to_owned(),
- WORKDIR.to_owned(),
- IMAGE.to_owned(),
- "sh".to_owned(),
- "-c".to_owned(),
- format!("{command} 2>&1"),
- ]
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn run_args_binds_work_toolchains_and_cache() {
- let args = run_args(
- Path::new("/tmp/s/work"),
- Path::new("/tmp/s/toolchains"),
- Path::new("/tmp/s/cache"),
- "cargo test",
- );
- assert_eq!(
- args,
- vec![
- "run",
- "--rm",
- "-v",
- "/tmp/s/work:/work",
- "-v",
- "/tmp/s/toolchains:/toolchains:ro",
- "-v",
- "/tmp/s/cache:/cache",
- "-w",
- "/work",
- IMAGE,
- "sh",
- "-c",
- "cargo test 2>&1",
- ]
- );
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn run_args_uses_the_minimal_base_image() {
- let args = run_args(Path::new("/w"), Path::new("/t"), Path::new("/c"), "true");
- assert_eq!(args.get(args.len() - 4).map(String::as_str), Some(IMAGE));
- }
-}
crates/git-effect/src/engine.rs
@@ -1,1678 +1,0 @@
-//! Asynchronous effect running: a `post-receive` hook that *queues* a push and
-//! a server-owned worker that runs the configured effects against it in a
-//! Fly.io [Sprite].
-//!
-//! Effects run *after* the refs are in and off the push connection. The hook
-//! ([`post_receive`]) does almost nothing: it reads the pushed ref updates git
-//! feeds it on stdin and drops a job file into the shared queue directory, so
-//! the push returns immediately. The long-running server drains that queue
-//! from a dedicated worker ([`worker`]); for each job it loads the effect set
-//! from [`crate::definition::EFFECTS_NS`] and runs every effect in a Sprite —
-//! a persistent, hardware-isolated sandbox. One Sprite is kept per repository
-//! so its filesystem (and any build cache an effect leaves behind) survives
-//! between pushes; the pushed tree is synced into it before the effects run.
-//! Results are recorded as run refs (and surfaced on the Checks tab), and
-//! logged to the server's own output rather than relayed to the pusher.
-//!
-//! The Sprite is driven through the `sprite` CLI. The CLI authenticates from a
-//! config file rather than the environment, so the worker first hands it the
-//! `SPRITES_TOKEN` the server passes down via `sprite auth setup`; only then
-//! does an organization become configured.
-//!
-//! [Sprite]: https://sprites.dev
-
-use std::collections::HashMap;
-use std::collections::HashSet;
-use std::io::Read;
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-use std::sync::mpsc::RecvTimeoutError;
-use std::sync::{Arc, Mutex as StdMutex, PoisonError};
-use std::time::{Duration, Instant};
-
-use gix_hash::ObjectId;
-use portable_pty::{CommandBuilder, PtySize, native_pty_system};
-use tokio::sync::Mutex;
-
-use crate::cache;
-use crate::definition::{self, Effect};
-use crate::docker;
-use crate::local;
-use crate::results::{self, RunOutcome, Status};
-
-/// Where the pushed tree is unpacked inside the Sprite.
-const WORKDIR: &str = "/work";
-
-/// Where resolved toolchains are extracted inside the Sprite, one directory
-/// per tree hash (`{TOOLCHAINS_DIR}/<hash>`) — unlike [`WORKDIR`], never
-/// cleared: the Sprite's persistent filesystem is the extract-once cache.
-const TOOLCHAINS_DIR: &str = "/toolchains";
-
-/// A currently-running effect's growing asciicast v2 recording, keyed by the
-/// repository, the commit being checked, and the effect's name.
-pub type LiveKey = (PathBuf, ObjectId, String);
-
-/// Live buffers for every effect currently running, shared between the
-/// worker thread appending to an effect's output as it arrives and the web
-/// layer polling it for a live view. A buffer exists only while its effect is
-/// running — [`live_start`] adds it, [`live_finish`] removes it once the
-/// result is recorded — so a lookup miss unambiguously means "not running"
-/// rather than "running with no output yet". Asciicast is the definitive log
-/// format end to end: the same string a live poll reads is, unmodified,
-/// what [`run_one`] hands back as the effect's recorded `recording`.
-pub type LiveRegistry = Arc<StdMutex<HashMap<LiveKey, Arc<StdMutex<String>>>>>;
-
-/// A fresh, empty [`LiveRegistry`] — one per server process, held on the
-/// server's shared state.
-#[must_use]
-pub fn new_live_registry() -> LiveRegistry {
- Arc::new(StdMutex::new(HashMap::new()))
-}
-
-/// The text accumulated so far for a running effect's live buffer, or `None`
-/// when no effect is running under `key` (finished, or never started).
-#[must_use]
-pub fn live_snapshot(registry: &LiveRegistry, key: &LiveKey) -> Option<String> {
- let buffer = lock(registry).get(key).cloned()?;
- Some(lock(&buffer).clone())
-}
-
-/// Register a fresh live buffer for `key`, returning the handle [`run_one`]
-/// appends to as the effect's output arrives.
-fn live_start(registry: &LiveRegistry, key: LiveKey) -> Arc<StdMutex<String>> {
- let buffer = Arc::new(StdMutex::new(String::new()));
- lock(registry).insert(key, Arc::clone(&buffer));
- buffer
-}
-
-/// Remove `key`'s live buffer once its effect has settled — recorded results
-/// are read from the run ref from then on, not the live registry.
-fn live_finish(registry: &LiveRegistry, key: &LiveKey) {
- lock(registry).remove(key);
-}
-
-/// Lock a [`StdMutex`], recovering the guard from a poisoned lock rather than
-/// panicking: a live buffer is best-effort output for a browser to look at,
-/// not something worth tearing the process down over if a prior panic
-/// poisoned it.
-pub(crate) fn lock<T>(mutex: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
- mutex.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-/// The environment variable through which the server hands the hook the queue
-/// directory; the worker is given the same path directly.
-pub const QUEUE_ENV: &str = "GIT_ENTS_CHECKS_QUEUE";
-
-/// How often the worker scans the queue directory for new jobs.
-const POLL: Duration = Duration::from_secs(2);
-
-/// Queue the push git is reporting for asynchronous effect running, returning
-/// `Ok(())` once the jobs are enqueued. The ref updates are read from the
-/// stdin git populates for a `post-receive` hook (`<old> <new> <ref>` lines).
-///
-/// The hook does no effect work itself: it writes one job file per updated
-/// branch into the shared queue directory ([`QUEUE_ENV`]) and returns, so the
-/// push is never blocked on a Sprite. The server's [`worker`] picks the jobs
-/// up.
-///
-/// ## Requirements
-///
-/// @relation(checks.post-receive, nonfunctional.push-latency)
-pub fn post_receive() -> Result<(), String> {
- let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?;
-
- let mut input = String::new();
- std::io::stdin()
- .read_to_string(&mut input)
- .map_err(|e| format!("could not read ref updates: {e}"))?;
- let updates = parse_updates(&input);
- if updates.is_empty() {
- return Ok(());
- }
-
- // An empty effect set leaves nothing to queue.
- let runnable =
- definition::load_all(&repo).map_err(|e| format!("could not read effects: {e}"))?;
- if runnable.is_empty() {
- return Ok(());
- }
-
- let Some(queue) = std::env::var_os(QUEUE_ENV).map(PathBuf::from) else {
- eprintln!("effects: {QUEUE_ENV} is not set; skipping asynchronous effects");
- return Ok(());
- };
-
- for update in updates {
- enqueue(&queue, &repo, &update)?;
- // Record the run as `queued` straight away so it shows up on the Checks
- // tab the moment the push lands, before the worker picks it up; a
- // recording hiccup is reported but never fails the hook.
- let queued = statuses(&runnable, Status::Queued);
- if let Err(e) = results::record(&repo, update.new, &queued) {
- eprintln!(
- "effects: could not record queued run for {}: {e}",
- update.new
- );
- }
- println!(
- "effects: queued {} effect(s) on {}",
- runnable.len(),
- update.ref_name
- );
- }
- Ok(())
-}
-
-/// Every effect's [`RunOutcome`] set to one shared `status` — the queued/running
-/// snapshot a run starts from before per-effect results land.
-fn statuses(effects: &[Effect], status: Status) -> Vec<RunOutcome> {
- effects
- .iter()
- .map(|effect| RunOutcome {
- name: effect.name.clone(),
- status,
- duration_secs: None,
- recording: None,
- exit_code: None,
- })
- .collect()
-}
-
-/// Run the worker that drains the queue directory, running and recording the
-/// effects for each queued push. Runs for the life of the server; the blocking
-/// Sprite work is offloaded so it never stalls the async runtime.
-///
-/// Jobs are processed per repository: each tick, every repository with pending
-/// jobs that is not already being worked gets its own blocking task that drains
-/// its jobs in order. Because an effect can run up to [`CHECK_TIMEOUT`], serving
-/// all repositories from one queue scan would let a single slow repository stall
-/// every other repository's effects; isolating them by repository keeps a slow
-/// repository's backlog from blocking the rest. Jobs for *one* repository stay
-/// serialized so concurrent runs never collide in its single Sprite.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker, nonfunctional.concurrency, checks.sandbox)
-pub async fn worker(queue: PathBuf, live: LiveRegistry, kind: BackendKind) {
- if let Err(e) = std::fs::create_dir_all(&queue) {
- eprintln!("effects: could not create queue directory {queue:?}: {e}");
- return;
- }
- let inflight: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
- let mut tick = tokio::time::interval(POLL);
- loop {
- tick.tick().await;
- let mut guard = inflight.lock().await;
- for (repo, jobs) in pending_jobs(&queue) {
- // Skip a repository already draining; its task will pick up any jobs
- // that arrived since on its next scan.
- if !guard.insert(repo.clone()) {
- continue;
- }
- let inflight = Arc::clone(&inflight);
- let live = live.clone();
- let handle = tokio::task::spawn_blocking(move || drain_repo(&jobs, &live, kind));
- tokio::spawn(async move {
- let _done = handle.await;
- inflight.lock().await.remove(&repo);
- });
- }
- }
-}
-
-/// Which sandbox a job's effects run in.
-///
-/// [`Sprite`](BackendKind::Sprite) is the hosted backend, driven through the
-/// `sprite` CLI. [`Docker`](BackendKind::Docker) is the local default (`git
-/// effect run`, and `git ents serve`'s own worker): a throwaway container per
-/// effect, with toolchains materialized on the host and bind-mounted in — see
-/// [`crate::local`] and [`crate::docker`]. [`Host`](BackendKind::Host) is
-/// `--unsandboxed`: the command runs directly on the machine running the
-/// worker, no isolation at all.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum BackendKind {
- /// The hosted Fly.io Sprite backend.
- Sprite,
- /// The local Docker backend.
- Docker,
- /// Host-direct execution (`--unsandboxed`), no sandbox at all.
- Host,
-}
-
-/// The backend `git ents serve`/`git-ents-server` fall back to when not told
-/// otherwise: [`BackendKind::Sprite`] when `SPRITES_TOKEN` is set in the
-/// environment — the hosted deployment's own signal that a Sprite is
-/// configured (see [`ensure_auth`]) — [`BackendKind::Docker`] otherwise. This
-/// is exactly the Deployment table's split: hosted mode always carries
-/// `SPRITES_TOKEN`, local `git ents serve` never does.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-#[must_use]
-pub fn default_backend() -> BackendKind {
- backend_for_token(std::env::var("SPRITES_TOKEN").ok().as_deref())
-}
-
-/// [`default_backend`]'s pure decision, taking `SPRITES_TOKEN`'s value
-/// directly rather than reading the environment — the part worth unit
-/// testing without mutating process-global state.
-fn backend_for_token(sprites_token: Option<&str>) -> BackendKind {
- if sprites_token.is_some() {
- BackendKind::Sprite
- } else {
- BackendKind::Docker
- }
-}
-
-/// The pending jobs in the queue directory grouped by repository, so each
-/// repository can be drained independently. A malformed job file is dropped here
-/// rather than grouped — a poison job is never retried.
-fn pending_jobs(queue: &Path) -> HashMap<PathBuf, Vec<(PathBuf, Job)>> {
- let mut groups: HashMap<PathBuf, Vec<(PathBuf, Job)>> = HashMap::new();
- let Ok(entries) = std::fs::read_dir(queue) else {
- return groups;
- };
- for entry in entries.flatten() {
- let path = entry.path();
- if path.extension().is_none_or(|ext| ext != "job") {
- continue;
- }
- match read_job(&path) {
- Some(job) => groups
- .entry(job.repo.clone())
- .or_default()
- .push((path, job)),
- None => {
- let _removed = std::fs::remove_file(&path);
- }
- }
- }
- groups
-}
-
-/// Drain one repository's queued jobs in order, deleting each job file after it
-/// is handled (whether it ran cleanly or failed) so it is never retried.
-fn drain_repo(jobs: &[(PathBuf, Job)], live: &LiveRegistry, kind: BackendKind) {
- for (path, job) in jobs {
- if let Err(e) = process_job(job, live, kind) {
- eprintln!("effects: {e}");
- }
- let _removed = std::fs::remove_file(path);
- }
-}
-
-/// Run one queued job's effects in `kind`'s backend, discarding the outcomes
-/// (already recorded — see [`run_all`]) since nothing else needs them here.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker)
-fn process_job(job: &Job, live: &LiveRegistry, kind: BackendKind) -> Result<(), String> {
- run_all(&job.repo, job.new, &job.ref_name, kind, live)?;
- Ok(())
-}
-
-/// Run every configured effect in `repo` against `at` outside the queue —
-/// `git effect run`'s local execution path. Identical toolchain
-/// materialization and sandbox path to a push-triggered run (see
-/// [`run_all`]); only the queue is skipped, exactly as the porcelain
-/// promises. `at` is a full hex commit id, already resolved by the caller.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker, cli.account-checks)
-pub fn run_effect_at(
- repo: &Path,
- at: &str,
- kind: BackendKind,
- live: &LiveRegistry,
-) -> Result<Vec<RunOutcome>, String> {
- let oid = ObjectId::from_hex(at.trim().as_bytes())
- .map_err(|e| format!("{at:?} is not a valid commit id: {e}"))?;
- run_all(repo, oid, "<local run>", kind, live)
-}
-
-/// Run every effect for `new` in `repo`'s given backend, advancing the
-/// recorded run as it goes: `running` while the sandbox is prepared, then
-/// each effect flipped to its result as it finishes. Effects settle in the
-/// dependency order `definition::order` fixed at write time: an effect whose
-/// dependency did not pass is recorded `skipped` without touching the
-/// sandbox, and a composite (no command) derives its status from its
-/// dependencies alone. An infra failure (an unreachable sandbox, a tree that
-/// will not sync, an effect set that fails re-validation) finalizes the run
-/// as `error` rather than leaving it stuck at `running`, then returns `Err`.
-/// Returns the settled outcomes on success — even one that includes a
-/// failing effect, which is a recorded result, not an error.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker, checks.sandbox)
-fn run_all(
- repo: &Path,
- new: ObjectId,
- ref_name: &str,
- kind: BackendKind,
- live: &LiveRegistry,
-) -> Result<Vec<RunOutcome>, String> {
- let runnable =
- definition::load_all(repo).map_err(|e| format!("could not read effects: {e}"))?;
- if runnable.is_empty() {
- return Ok(Vec::new());
- }
-
- let mut outcomes = statuses(&runnable, Status::Running);
- // Re-validate defensively: the CLI rejects an invalid graph before it is
- // pushed, but a hand-crafted push could still land one. Indices into
- // `runnable`/`outcomes` rather than borrows, so outcomes stay mutable.
- let ordered: Vec<usize> = match definition::order(&runnable) {
- Ok(ordered) => ordered
- .iter()
- .filter_map(|effect| runnable.iter().position(|c| c.name == effect.name))
- .collect(),
- Err(e) => {
- finalize_error(repo, new, &mut outcomes);
- return Err(format!("invalid effect set: {e}"));
- }
- };
-
- let backend = match Backend::new(kind, repo) {
- Ok(backend) => backend,
- Err(e) => {
- finalize_error(repo, new, &mut outcomes);
- return Err(e);
- }
- };
- if let Err(e) = backend.ensure() {
- finalize_error(repo, new, &mut outcomes);
- return Err(e);
- }
-
- eprintln!(
- "effects: running {} effect(s) on {}",
- runnable.len(),
- ref_name
- );
- advance(repo, new, &outcomes);
- if let Err(e) = backend.sync_tree(repo, new) {
- finalize_error(repo, new, &mut outcomes);
- return Err(e);
- }
-
- let toolchain_dirs = match backend.resolve_toolchains(repo, &runnable) {
- Ok(dirs) => dirs,
- Err(e) => {
- finalize_error(repo, new, &mut outcomes);
- return Err(e);
- }
- };
-
- let mut cache_names: Vec<&str> = runnable
- .iter()
- .filter_map(|effect| effect.cache.as_deref())
- .collect();
- cache_names.sort_unstable();
- cache_names.dedup();
- for name in cache_names {
- if let Err(e) = backend.restore_cache(repo, name) {
- finalize_error(repo, new, &mut outcomes);
- return Err(e);
- }
- }
-
- for index in ordered {
- let Some(effect) = runnable.get(index) else {
- continue;
- };
- // Topological order guarantees every dependency settled already.
- let deps: Vec<Status> = effect
- .depends
- .iter()
- .filter_map(|dep| {
- outcomes
- .iter()
- .find(|outcome| outcome.name == *dep)
- .map(|outcome| outcome.status)
- })
- .collect();
- let all_pass = deps.iter().all(|status| *status == Status::Pass);
- match &effect.command {
- Some(command) if all_pass => {
- let command = activate(command, &effect.toolchains, &toolchain_dirs);
- let cache_dir = effect
- .cache
- .as_deref()
- .map(|name| backend.cache_dir_for(name));
- let command = with_cache_env(&command, cache_dir.as_deref());
- let key: LiveKey = (repo.to_path_buf(), new, effect.name.clone());
- let buffer = live_start(live, key.clone());
- let result = backend.run_one(&effect.name, &command, &buffer);
- if let Some(name) = &effect.cache
- && let Err(e) = backend.snapshot_cache(repo, name)
- {
- eprintln!("effects: could not snapshot cache {name}: {e}");
- }
- if let Some(outcome) = outcomes.get_mut(index) {
- outcome.status = result.status;
- outcome.duration_secs = Some(result.duration_secs);
- outcome.recording = Some(result.recording);
- outcome.exit_code = result.exit_code;
- }
- advance(repo, new, &outcomes);
- live_finish(live, &key);
- continue;
- }
- Some(_) => {
- eprintln!("effects: SKIP {} (a dependency did not pass)", effect.name);
- if let Some(outcome) = outcomes.get_mut(index) {
- outcome.status = Status::Skipped;
- }
- }
- None => {
- let status = derive_composite(&deps);
- eprintln!(
- "effects: {} {} (composite)",
- status.to_string().to_uppercase(),
- effect.name
- );
- if let Some(outcome) = outcomes.get_mut(index) {
- outcome.status = status;
- }
- }
- }
- advance(repo, new, &outcomes);
- }
- Ok(outcomes)
-}
-
-/// One ready-to-use sandbox backend: the Sprite's name, or a fresh
-/// [`local::Sandbox`] materialized on the host for the Docker or host-direct
-/// backends. Constructing it is the one place a backend-specific setup
-/// failure (no `docker` on `PATH`, no scratch directory) surfaces before any
-/// sandbox work starts.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-enum Backend {
- Sprite(String),
- Docker(local::Sandbox),
- Host(local::Sandbox),
-}
-
-impl Backend {
- fn new(kind: BackendKind, repo: &Path) -> Result<Self, String> {
- match kind {
- BackendKind::Sprite => Ok(Backend::Sprite(sprite_name(repo))),
- BackendKind::Docker => {
- docker::ensure_docker()?;
- Ok(Backend::Docker(local::Sandbox::new()?))
- }
- BackendKind::Host => Ok(Backend::Host(local::Sandbox::new()?)),
- }
- }
-
- /// Sprite-only setup (auth, create-if-absent); the local backends need
- /// none, since [`Backend::new`] already prepared their sandbox.
- fn ensure(&self) -> Result<(), String> {
- match self {
- Backend::Sprite(name) => ensure_auth().and_then(|()| ensure_sprite(name)),
- Backend::Docker(_) | Backend::Host(_) => Ok(()),
- }
- }
-
- fn sync_tree(&self, repo: &Path, new: ObjectId) -> Result<(), String> {
- match self {
- Backend::Sprite(name) => sync_tree(repo, name, new),
- Backend::Docker(sandbox) | Backend::Host(sandbox) => {
- local::sync_tree(repo, sandbox, new)
- }
- }
- }
-
- /// A `name -> PATH entry` map: an in-Sprite/in-container path for the
- /// Sprite and Docker backends, the real host path for host-direct
- /// execution, since it runs with no container to bind-mount into.
- fn resolve_toolchains(
- &self,
- repo: &Path,
- runnable: &[Effect],
- ) -> Result<HashMap<String, String>, String> {
- match self {
- Backend::Sprite(name) => resolve_toolchains(repo, name, runnable),
- Backend::Docker(sandbox) => {
- let names = local::resolve_toolchains(repo, sandbox, runnable)?;
- Ok(names
- .into_iter()
- .map(|name| {
- let dir = format!("{}/{name}/bin", docker::TOOLCHAINS_DIR);
- (name, dir)
- })
- .collect())
- }
- Backend::Host(sandbox) => {
- let names = local::resolve_toolchains(repo, sandbox, runnable)?;
- Ok(local::host_toolchain_dirs(sandbox, &names))
- }
- }
- }
-
- fn restore_cache(&self, repo: &Path, name: &str) -> Result<(), String> {
- match self {
- Backend::Sprite(sprite) => cache::restore(repo, sprite, name),
- Backend::Docker(sandbox) | Backend::Host(sandbox) => {
- cache::restore_local(repo, &sandbox.cache_dir(name), name)
- }
- }
- }
-
- fn snapshot_cache(&self, repo: &Path, name: &str) -> Result<(), String> {
- match self {
- Backend::Sprite(sprite) => cache::snapshot(repo, sprite, name),
- Backend::Docker(sandbox) | Backend::Host(sandbox) => {
- cache::snapshot_local(repo, &sandbox.cache_dir(name), name)
- }
- }
- }
-
- fn cache_dir_for(&self, name: &str) -> String {
- match self {
- Backend::Sprite(_) => cache::cache_dir(name),
- Backend::Docker(_) => format!("{}/{name}", docker::CACHE_DIR),
- Backend::Host(sandbox) => sandbox.cache_dir(name).display().to_string(),
- }
- }
-
- fn run_one(&self, name: &str, command: &str, live: &Arc<StdMutex<String>>) -> RunResult {
- match self {
- Backend::Sprite(sprite) => run_one(sprite, name, command, live),
- Backend::Docker(sandbox) => run_one_docker(sandbox, name, command, live),
- Backend::Host(sandbox) => run_one_host(sandbox, name, command, live),
- }
- }
-}
-
-/// A composite effect's status, derived from its dependencies' settled
-/// statuses: `pass` when everything passed, `fail` when anything failed or
-/// errored, `skipped` when nothing failed but something was skipped.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker)
-fn derive_composite(deps: &[Status]) -> Status {
- if deps.iter().all(|status| *status == Status::Pass) {
- Status::Pass
- } else if deps
- .iter()
- .any(|status| matches!(status, Status::Fail | Status::Error))
- {
- Status::Fail
- } else {
- Status::Skipped
- }
-}
-
-/// Advance the recorded run for `new` to `outcomes`; a recording hiccup is
-/// logged but never derails the worker.
-fn advance(repo: &Path, new: ObjectId, outcomes: &[RunOutcome]) {
- if let Err(e) = results::update_run(repo, new, outcomes) {
- eprintln!("effects: could not record run for {new}: {e}");
- }
-}
-
-/// Mark every effect in `outcomes` `error` and record it — the terminal state
-/// for a run the worker could not carry out.
-///
-/// ## Requirements
-///
-/// @relation(checks.worker)
-fn finalize_error(repo: &Path, new: ObjectId, outcomes: &mut [RunOutcome]) {
- for outcome in outcomes.iter_mut() {
- outcome.status = Status::Error;
- }
- advance(repo, new, outcomes);
-}
-
-/// One queued push: the repository to check, the new tip to check, and the ref
-/// it updated (carried only for logging).
-struct Job {
- repo: PathBuf,
- new: ObjectId,
- ref_name: String,
-}
-
-/// Write a job for `update` into `queue` as a three-line file (`repo`, new oid,
-/// ref). The file is written under a `.tmp` name and renamed into place so the
-/// worker never observes a half-written job.
-///
-/// ## Requirements
-///
-/// @relation(checks.post-receive)
-fn enqueue(queue: &Path, repo: &Path, update: &Update) -> Result<(), String> {
- std::fs::create_dir_all(queue)
- .map_err(|e| format!("could not create queue directory {queue:?}: {e}"))?;
- let stem = job_stem();
- let tmp = queue.join(format!("{stem}.tmp"));
- let final_path = queue.join(format!("{stem}.job"));
- let body = format!("{}\n{}\n{}\n", repo.display(), update.new, update.ref_name);
- std::fs::write(&tmp, body).map_err(|e| format!("could not write job: {e}"))?;
- std::fs::rename(&tmp, &final_path).map_err(|e| format!("could not enqueue job: {e}"))?;
- Ok(())
-}
-
-/// A unique job file stem so concurrent pushes never collide on a queue file
-/// name.
-fn job_stem() -> String {
- uuid::Uuid::new_v4().to_string()
-}
-
-/// Parse a queued job file (`repo`, new oid, ref, one per line), or `None` when
-/// it is malformed.
-fn read_job(path: &Path) -> Option<Job> {
- let contents = std::fs::read_to_string(path).ok()?;
- let mut lines = contents.lines();
- let repo = PathBuf::from(lines.next()?);
- let new = ObjectId::from_hex(lines.next()?.as_bytes()).ok()?;
- let ref_name = lines.next()?.to_owned();
- Some(Job {
- repo,
- new,
- ref_name,
- })
-}
-
-/// One ref git reported as updated by the push.
-struct Update<'a> {
- new: ObjectId,
- ref_name: &'a str,
-}
-
-/// Refname prefixes an effect can never be triggered by, no matter how broad
-/// a trigger pattern gets (even `refs/meta/*` or `refs/*`): a push under
-/// [`crate::results::RESULTS_NS`] is an effect's own recorded outcome, and a
-/// future `refs/meta/index/*` namespace is server-maintained derived state —
-/// letting either enqueue effects would let a result (or an index update)
-/// trigger the effect that produced it, recursing forever. Checked ahead of,
-/// and independently from, the broader `refs/meta/` exclusion in
-/// [`parse_updates`], so the invariant holds even once a per-effect `trigger`
-/// pattern exists and could otherwise opt into these namespaces.
-const NEVER_TRIGGERS: &[&str] = &["refs/meta/results/", "refs/meta/index/"];
-
-/// Whether `ref_name` may ever enqueue effects. Always `false` for
-/// [`NEVER_TRIGGERS`]' namespaces, regardless of any trigger pattern an
-/// effect declares.
-fn triggers_effects(ref_name: &str) -> bool {
- !NEVER_TRIGGERS
- .iter()
- .any(|prefix| ref_name.starts_with(prefix))
-}
-
-/// Parse git's `<old-oid> <new-oid> <ref>` stdin into the updates worth
-/// checking: branch updates with a real new tip. Deletions (a zero new oid),
-/// the `refs/meta/*` control refs (auth, the effect set itself), and anything
-/// under [`NEVER_TRIGGERS`] are skipped — the effects gate ordinary content,
-/// not the trust plumbing or an effect's own recorded results.
-fn parse_updates(input: &str) -> Vec<Update<'_>> {
- input
- .lines()
- .filter_map(|line| {
- let mut fields = line.split_whitespace();
- let _old = fields.next()?;
- let new = fields.next()?;
- let ref_name = fields.next()?;
- let new = ObjectId::from_hex(new.as_bytes()).ok()?;
- if new.is_null() || ref_name.starts_with("refs/meta/") || !triggers_effects(ref_name) {
- None
- } else {
- Some(Update { new, ref_name })
- }
- })
- .collect()
-}
-
-/// A Sprite name derived from the repository directory, kept to the
-/// `[a-z0-9-]` a Sprite name allows so the same repo reuses the same sandbox.
-///
-/// Shared with the web layer's debug-session broker, which targets the same
-/// persistent per-repo Sprite an effect run used.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-#[must_use]
-pub fn sprite_name(repo: &Path) -> String {
- let stem = repo
- .file_name()
- .map(|name| name.to_string_lossy())
- .unwrap_or_else(|| "repo".into());
- let sanitized: String = stem
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() {
- c.to_ascii_lowercase()
- } else {
- '-'
- }
- })
- .collect();
- let trimmed = sanitized.trim_matches('-');
- format!(
- "checks-{}",
- if trimmed.is_empty() { "repo" } else { trimmed }
- )
-}
-
-/// Configure the `sprite` CLI from the `SPRITES_TOKEN` the server passes down.
-/// The CLI persists its credentials to a config file rather than reading the
-/// token per call, so without this it reports "no organizations configured"
-/// even with the token in the environment. `auth setup` is idempotent, so it is
-/// run on every push to keep the steady state self-healing.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox, compat.sprite)
-pub fn ensure_auth() -> Result<(), String> {
- let token = std::env::var("SPRITES_TOKEN")
- .ok()
- .ok_or("SPRITES_TOKEN is not set in the hook environment")?;
- let output = Command::new("sprite")
- .args(["auth", "setup", "--token", &token])
- .output()
- .map_err(|e| format!("could not run the sprite CLI (is it installed?): {e}"))?;
- if output.status.success() {
- Ok(())
- } else {
- Err(format!(
- "sprite auth setup failed: {}",
- String::from_utf8_lossy(&output.stderr).trim()
- ))
- }
-}
-
-/// Create the repository's Sprite if it does not already exist. `sprite create`
-/// fails when the Sprite is already there, which is the steady state once the
-/// first push has run, so its failure is tolerated and surfaces only later if
-/// the Sprite turns out to be unreachable.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox, compat.sprite)
-pub fn ensure_sprite(sprite: &str) -> Result<(), String> {
- let _existing = Command::new("sprite")
- .args(["create", "--skip-console", sprite])
- .output()
- .map_err(|e| format!("could not run the sprite CLI (is it installed?): {e}"))?;
- Ok(())
-}
-
-/// Stream the pushed tree at `new` into the Sprite's [`WORKDIR`] via
-/// [`unpack_script`]. `git archive` emits the tree as a tar that the Sprite
-/// unpacks over stdin.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox, compat.sprite, compat.git)
-fn sync_tree(repo: &Path, sprite: &str, new: ObjectId) -> Result<(), String> {
- let mut archive = Command::new("git");
- archive
- .arg("-C")
- .arg(repo)
- .args(["archive", "--format=tar", &new.to_string()]);
- let script = unpack_script();
- let mut unpack = Command::new("sprite");
- unpack.args(["exec", "-s", sprite, "--", "sh", "-c", &script]);
- crate::stream::pipe(archive, unpack, &format!("syncing the tree at {new}"))
-}
-
-/// The in-sprite script that replaces [`WORKDIR`]'s contents with the tar
-/// streamed over stdin, leaving the rest of the persistent filesystem (build
-/// caches and the like) intact.
-///
-/// It first kills any process still working under [`WORKDIR`]: a worker
-/// killed mid-run (a deploy, a restart) leaves its in-sprite build processes
-/// alive, since `sprite exec` only tethers the local CLI process — and an
-/// orphaned build still writing under [`WORKDIR`] races the wipe, failing
-/// `rm -rf` with "Directory not empty". The final `rm -rf && mkdir && tar`
-/// chain is what the exec's exit status reflects, as before.
-fn unpack_script() -> String {
- format!(
- "for cwd in /proc/[0-9]*/cwd; do\n\
- case \"$(readlink \"$cwd\" 2>/dev/null)\" in\n\
- {WORKDIR}|{WORKDIR}/*) kill -9 \"$(basename \"${{cwd%/cwd}}\")\" 2>/dev/null || true ;;\n\
- esac\n\
- done\n\
- rm -rf {WORKDIR} && mkdir -p {WORKDIR} && tar -x -C {WORKDIR}"
- )
-}
-
-/// Resolve and extract every distinct toolchain named across `runnable`,
-/// returning each name's extracted `bin` directory inside the Sprite. A
-/// failed resolution (the named ref does not exist) is the one place
-/// `definition::order` could not have caught it, since `refs/meta/toolchains/*`
-/// is a different namespace than the effect set itself.
-///
-/// ## Requirements
-///
-/// @relation(checks.toolchains, checks.sandbox)
-fn resolve_toolchains(
- repo: &Path,
- sprite: &str,
- runnable: &[Effect],
-) -> Result<HashMap<String, String>, String> {
- let mut names: Vec<&str> = runnable
- .iter()
- .flat_map(|effect| effect.toolchains.iter().map(String::as_str))
- .collect();
- names.sort_unstable();
- names.dedup();
-
- let mut dirs = HashMap::new();
- for name in names {
- let toolchain = git_toolchain::resolve(repo, name)
- .map_err(|e| format!("could not resolve toolchain {name}: {e}"))?;
- let dir = match &toolchain.bin {
- git_toolchain::Bin::Embedded(tree) => {
- let tree = tree.oid();
- sync_toolchain(repo, sprite, tree)?;
- format!("{TOOLCHAINS_DIR}/{tree}")
- }
- git_toolchain::Bin::Downloaded(components) => {
- let key = components_key(components);
- sync_downloaded_toolchain(sprite, &key, components)?;
- // Unlike an embedded toolchain's tree (already flattened to
- // put executables at its own top level), each component
- // extracts per its recorded layout — its own `bin/` top level
- // (rustup) or straight into a `bin` dest (a flat archive) —
- // landing executables at `<key>/bin` either way, so `PATH`
- // points one level deeper.
- format!("{TOOLCHAINS_DIR}/{key}/bin")
- }
- };
- dirs.insert(name.to_owned(), dir);
- }
- Ok(dirs)
-}
-
-/// A stable, filesystem-safe cache key for a [`git_toolchain::Bin::Downloaded`]
-/// toolchain: each component's sha256 plus its recorded layout
-/// (`strip`/`dest` — the same bytes extracted differently are a different
-/// toolchain on disk), joined in extraction order — there is no tree oid to
-/// key the extraction cache by, since nothing is written to the object
-/// database for a downloaded toolchain's `bin`.
-fn components_key(components: &[git_toolchain::Component]) -> String {
- components
- .iter()
- .map(|component| {
- format!(
- "{}.{}.{}",
- component.sha256, component.strip, component.dest
- )
- })
- .collect::<Vec<_>>()
- .join("-")
-}
-
-/// Prefix `command` with a `PATH` export activating `toolchains`' extracted
-/// `bin` directories, declared order first (so the first-listed toolchain's
-/// `bin` wins on a name collision); an effect with no toolchains is returned
-/// unchanged.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-pub(crate) fn activate(
- command: &str,
- toolchains: &[String],
- dirs: &HashMap<String, String>,
-) -> String {
- if toolchains.is_empty() {
- return command.to_owned();
- }
- let path = toolchains
- .iter()
- .filter_map(|name| dirs.get(name))
- .map(String::as_str)
- .collect::<Vec<_>>()
- .join(":");
- format!("export PATH={path}:$PATH; {command}")
-}
-
-/// Prefix `command` with an `EFFECT_CACHE_DIR` export pointing at
-/// `cache_dir` (the cache's restored directory in whichever backend is
-/// running — see [`Backend::cache_dir_for`]), so the command can point a
-/// tool (`sccache`, ...) at it; an effect with no cache is returned
-/// unchanged.
-///
-/// ## Requirements
-///
-/// @relation(checks.cache)
-pub(crate) fn with_cache_env(command: &str, cache_dir: Option<&str>) -> String {
- match cache_dir {
- Some(dir) => format!("export EFFECT_CACHE_DIR={dir}; {command}"),
- None => command.to_owned(),
- }
-}
-
-/// Extract the toolchain tree `tree` into the Sprite at
-/// `{TOOLCHAINS_DIR}/<tree>`, once — a directory already there from an
-/// earlier push is left alone rather than re-extracted, since the Sprite's
-/// persistent filesystem is the cache. Checked before running `git archive`
-/// so an already-cached toolchain never streams its (potentially large)
-/// contents through a pipe the Sprite has no reason to read.
-///
-/// Extraction happens into a sibling `.tmp` directory and only lands at `dir`
-/// via a final `mv`, so a transient failure partway through (e.g. a truncated
-/// stream) never leaves `dir` existing-but-incomplete: the next push's cache
-/// check sees no directory at all and retries, instead of trusting a half
-/// extraction forever.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-fn sync_toolchain(repo: &Path, sprite: &str, tree: ObjectId) -> Result<(), String> {
- let dir = format!("{TOOLCHAINS_DIR}/{tree}");
- let cached = Command::new("sprite")
- .args([
- "exec",
- "-s",
- sprite,
- "--",
- "sh",
- "-c",
- &format!("[ -d {dir} ]"),
- ])
- .status()
- .map_err(|e| format!("could not run the sprite CLI: {e}"))?;
- if cached.success() {
- return Ok(());
- }
-
- let mut archive = Command::new("git");
- archive
- .arg("-C")
- .arg(repo)
- .args(["archive", "--format=tar", &tree.to_string()]);
- let tmp = format!("{dir}.tmp");
- let script = format!(
- "rm -rf {tmp} && mkdir -p {tmp} && tar -x -C {tmp} && rm -rf {dir} && mv {tmp} {dir}"
- );
- let mut unpack = Command::new("sprite");
- unpack.args(["exec", "-s", sprite, "--", "sh", "-c", &script]);
- crate::stream::pipe(archive, unpack, &format!("syncing toolchain {tree}"))
-}
-
-/// Fetch, sha256-verify, and extract a [`git_toolchain::Bin::Downloaded`]
-/// toolchain's components into the Sprite at `{TOOLCHAINS_DIR}/<key>`, once —
-/// same cache-once discipline as [`sync_toolchain`], keyed by
-/// [`components_key`] since there is no tree oid to key by. Verification and
-/// extraction both happen inside the Sprite via `curl`/`sha256sum`/`tar`,
-/// mirroring `git_toolchain::export`'s local equivalent: downloading through
-/// the server first and streaming the bytes in would defeat the point of not
-/// storing them.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-fn sync_downloaded_toolchain(
- sprite: &str,
- key: &str,
- components: &[git_toolchain::Component],
-) -> Result<(), String> {
- let dir = format!("{TOOLCHAINS_DIR}/{key}");
- let cached = Command::new("sprite")
- .args([
- "exec",
- "-s",
- sprite,
- "--",
- "sh",
- "-c",
- &format!("[ -d {dir} ]"),
- ])
- .status()
- .map_err(|e| format!("could not run the sprite CLI: {e}"))?;
- if cached.success() {
- return Ok(());
- }
-
- let script = downloaded_script(&dir, components);
- let status = Command::new("sprite")
- .args(["exec", "-s", sprite, "--", "sh", "-c", &script])
- .status()
- .map_err(|e| format!("could not run the sprite CLI: {e}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!(
- "could not fetch and extract downloaded toolchain {key} in the sprite"
- ))
- }
-}
-
-/// The `sh` script fetching, verifying, and extracting `components` into
-/// `dir` — pure, so the exact extraction semantics the Sprite runs are unit
-/// tested against `git_toolchain::export`'s local equivalent (the
-/// local/hosted parity anchor). Each component lands in `dir`/its `dest`,
-/// stripped of its leading `strip` path segments, compression auto-detected
-/// by `tar` (rust-lang ships gzip, zig ships xz). Interpolation is safe by
-/// construction: `git_toolchain::import_downloaded` refuses a component
-/// whose fields could escape the single quotes.
-///
-/// Every component extracts into a sibling `.tmp` directory first; `dir`
-/// itself is only populated by the final `mv`, once every component has
-/// fetched, verified, and extracted successfully. A mid-script failure (a
-/// flaky `curl`, a hash mismatch) then leaves no directory at `dir` at all,
-/// so [`sync_downloaded_toolchain`]'s cache check retries on the next push
-/// instead of reusing a partially-extracted toolchain forever.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-fn downloaded_script(dir: &str, components: &[git_toolchain::Component]) -> String {
- let tmp = format!("{dir}.tmp");
- let mut script = format!("rm -rf {tmp} && mkdir -p {tmp}");
- for component in components {
- let dest = if component.dest.is_empty() {
- tmp.clone()
- } else {
- format!("{tmp}/{}", component.dest)
- };
- script.push_str(&format!(
- " && mkdir -p {dest} \
- && curl -fsSL '{url}' -o /tmp/component.archive \
- && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = '{sha256}' ] \
- && tar -x --strip-components={strip} -C {dest} -f /tmp/component.archive \
- && rm -f /tmp/component.archive",
- url = component.url,
- sha256 = component.sha256,
- strip = component.strip,
- ));
- }
- script.push_str(&format!(" && rm -rf {dir} && mv {tmp} {dir}"));
- script
-}
-
-/// How long a single effect may run before the worker abandons it. A runaway
-/// effect that outlived this — a hung build, a command blocked on input — is
-/// killed and recorded `error` rather than wedging the worker (and with it every
-/// other repository's effects) on the one blocking-pool thread the queue drains
-/// on.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-const CHECK_TIMEOUT: Duration = Duration::from_secs(30 * 60);
-
-/// The fixed size an effect's recorded terminal session runs at. Nothing
-/// interactive ever attaches to it, so this only shapes the recording, not
-/// anyone's actual terminal.
-const CHECK_PTY_SIZE: PtySize = PtySize {
- rows: 24,
- cols: 80,
- pixel_width: 0,
- pixel_height: 0,
-};
-
-/// A finished effect run: its outcome, wall-clock duration, process exit code
-/// (when the command ran to completion), and the full terminal session as an
-/// asciicast v2 recording.
-pub(crate) struct RunResult {
- pub(crate) status: Status,
- pub(crate) duration_secs: u64,
- pub(crate) recording: String,
- pub(crate) exit_code: Option<i32>,
-}
-
-/// Run one effect in the Sprite's [`WORKDIR`], recording its terminal session —
-/// a real pty (`sprite exec --tty`), not a pipe, so the recording plays back
-/// exactly what a developer running the effect by hand would see — and logging
-/// a `PASS`/`FAIL` line. `live` is appended to as output arrives, in the same
-/// asciicast v2 format as the final recording, so a browser can poll it for a
-/// live view of an effect still in progress; it is what [`finish`] hands back
-/// as the recorded `recording`, not a separate representation of the same
-/// output. Returns the effect's outcome; an effect that exceeds
-/// [`CHECK_TIMEOUT`] or cannot be captured is [`Status::Error`].
-///
-/// ## Requirements
-///
-/// @relation(compat.sprite)
-fn run_one(sprite: &str, name: &str, command: &str, live: &Arc<StdMutex<String>>) -> RunResult {
- let start = Instant::now();
- lock(live).push_str(&asciicast_header());
-
- let pair = match native_pty_system().openpty(CHECK_PTY_SIZE) {
- Ok(pair) => pair,
- Err(e) => {
- eprintln!("effects: ERROR {name} (could not allocate a pty: {e})");
- return finish(Status::Error, start, None, live);
- }
- };
- let mut cmd = CommandBuilder::new("sprite");
- cmd.args([
- "exec", "--tty", "-s", sprite, "--dir", WORKDIR, "--", "sh", "-c", command,
- ]);
- let mut child = match pair.slave.spawn_command(cmd) {
- Ok(child) => child,
- Err(e) => {
- eprintln!("effects: ERROR {name} (could not run: {e})");
- return finish(Status::Error, start, None, live);
- }
- };
- // The child holds the slave now; drop ours so the master sees EOF when the
- // effect process actually exits rather than when this scope happens to end.
- drop(pair.slave);
-
- let master = pair.master;
- let Ok(reader) = master.try_clone_reader() else {
- eprintln!("effects: ERROR {name} (could not read the pty)");
- let _killed = child.kill();
- return finish(Status::Error, start, None, live);
- };
-
- let timed_out = drain(reader, start, live);
- drop(master);
-
- if timed_out {
- eprintln!("effects: ERROR {name} (timed out after {CHECK_TIMEOUT:?})");
- let _killed = child.kill();
- return finish(Status::Error, start, None, live);
- }
-
- let status = match child.wait() {
- Ok(status) => status,
- Err(e) => {
- eprintln!("effects: ERROR {name} (could not wait on the sprite CLI: {e})");
- return finish(Status::Error, start, None, live);
- }
- };
-
- let exit_code = Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX));
- if status.success() {
- eprintln!("effects: PASS {name}");
- finish(Status::Pass, start, exit_code, live)
- } else {
- eprintln!("effects: FAIL {name} ({command})");
- finish(Status::Fail, start, exit_code, live)
- }
-}
-
-/// Run one effect in the Docker backend's throwaway `--rm` container, per
-/// [`docker::run_args`]. Otherwise identical to [`run_one`]: same timeout,
-/// same asciicast recording, same `live` buffer — just a plain pipe instead
-/// of a pty, since nothing here needs an interactive terminal, only a
-/// captured one.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-pub(crate) fn run_one_docker(
- sandbox: &local::Sandbox,
- name: &str,
- command: &str,
- live: &Arc<StdMutex<String>>,
-) -> RunResult {
- let start = Instant::now();
- lock(live).push_str(&asciicast_header());
-
- let args = docker::run_args(
- &sandbox.work_dir(),
- &sandbox.toolchains_dir(),
- &sandbox.cache_root(),
- command,
- );
- let mut cmd = Command::new("docker");
- cmd.args(&args);
- run_captured(&mut cmd, name, command, start, live)
-}
-
-/// Run one effect directly on the host (`--unsandboxed`), in the sandbox's
-/// materialized work directory — no container, no isolation. Otherwise
-/// identical to [`run_one_docker`].
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-fn run_one_host(
- sandbox: &local::Sandbox,
- name: &str,
- command: &str,
- live: &Arc<StdMutex<String>>,
-) -> RunResult {
- let start = Instant::now();
- lock(live).push_str(&asciicast_header());
-
- let mut cmd = Command::new("sh");
- cmd.arg("-c")
- .arg(format!("{command} 2>&1"))
- .current_dir(sandbox.work_dir());
- run_captured(&mut cmd, name, command, start, live)
-}
-
-/// Spawn `cmd` (already built, stdout not yet configured), capture its
-/// combined output into `live` via [`drain`], and assemble the [`RunResult`]
-/// — the part [`run_one_docker`] and [`run_one_host`] share.
-fn run_captured(
- cmd: &mut Command,
- name: &str,
- command: &str,
- start: Instant,
- live: &Arc<StdMutex<String>>,
-) -> RunResult {
- let mut child = match cmd.stdin(Stdio::null()).stdout(Stdio::piped()).spawn() {
- Ok(child) => child,
- Err(e) => {
- eprintln!("effects: ERROR {name} (could not run: {e})");
- return finish(Status::Error, start, None, live);
- }
- };
- let Some(stdout) = child.stdout.take() else {
- eprintln!("effects: ERROR {name} (could not capture output)");
- let _killed = child.kill();
- return finish(Status::Error, start, None, live);
- };
-
- let timed_out = drain(stdout, start, live);
- if timed_out {
- eprintln!("effects: ERROR {name} (timed out after {CHECK_TIMEOUT:?})");
- let _killed = child.kill();
- return finish(Status::Error, start, None, live);
- }
-
- let status = match child.wait() {
- Ok(status) => status,
- Err(e) => {
- eprintln!("effects: ERROR {name} (could not wait: {e})");
- return finish(Status::Error, start, None, live);
- }
- };
-
- let exit_code = status.code();
- if status.success() {
- eprintln!("effects: PASS {name}");
- finish(Status::Pass, start, exit_code, live)
- } else {
- eprintln!("effects: FAIL {name} ({command})");
- finish(Status::Fail, start, exit_code, live)
- }
-}
-
-/// Read `reader` until EOF or [`CHECK_TIMEOUT`] elapses since `start`,
-/// appending each chunk to `live` as an asciicast v2 output event, exactly
-/// the format [`run_one`]'s pty capture already produces. Shared by every
-/// backend so the Checks tab's live/final recording looks the same
-/// regardless of which one ran: the Sprite backend feeds this a pty's
-/// reader, the Docker/host backends a plain child pipe. `reader`'s own
-/// (blocking) read runs on a dedicated thread; the caller's thread only
-/// waits on a channel, so it can time out the whole run without depending on
-/// the read itself returning promptly. Returns whether the timeout (rather
-/// than EOF) ended the read.
-fn drain(
- mut reader: impl Read + Send + 'static,
- start: Instant,
- live: &Arc<StdMutex<String>>,
-) -> bool {
- let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
- std::thread::spawn(move || {
- let mut buf = [0u8; 4096];
- loop {
- match reader.read(&mut buf) {
- Ok(0) | Err(_) => break,
- Ok(n) => {
- let Some(chunk) = buf.get(..n) else { break };
- if tx.send(chunk.to_vec()).is_err() {
- break;
- }
- }
- }
- }
- });
-
- let deadline = start.checked_add(CHECK_TIMEOUT).unwrap_or(start);
- loop {
- let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
- return true;
- };
- match rx.recv_timeout(remaining) {
- Ok(chunk) => {
- let elapsed = start.elapsed().as_secs_f64();
- let data = String::from_utf8_lossy(&chunk);
- push_event(&mut lock(live), elapsed, &data);
- }
- Err(RecvTimeoutError::Timeout) => return true,
- Err(RecvTimeoutError::Disconnected) => return false,
- }
- }
-}
-
-/// Assemble a [`RunResult`] from `live`'s accumulated recording — used on
-/// every exit path, including the failure ones, so an effect that errors out
-/// still keeps whatever terminal output it produced before that happened.
-fn finish(
- status: Status,
- start: Instant,
- exit_code: Option<i32>,
- live: &StdMutex<String>,
-) -> RunResult {
- RunResult {
- status,
- duration_secs: start.elapsed().as_secs(),
- recording: lock(live).clone(),
- exit_code,
- }
-}
-
-/// The asciicast v2 header line naming the terminal's fixed [`CHECK_PTY_SIZE`]
-/// — the first line of every effect recording, live or finished (see
-/// <https://docs.asciinema.org/manual/asciicast/v2/>).
-fn asciicast_header() -> String {
- format!(
- "{{\"version\": 2, \"width\": {}, \"height\": {}}}\n",
- CHECK_PTY_SIZE.cols, CHECK_PTY_SIZE.rows
- )
-}
-
-/// Append one asciicast v2 `[time, "o", data]` output event to `out`, the
-/// Checks tab's replay format for a chunk of pty output captured `time`
-/// seconds into the run.
-fn push_event(out: &mut String, time: f64, data: &str) {
- out.push('[');
- out.push_str(&format!("{time:.6}"));
- out.push_str(", \"o\", ");
- push_json_string(data, out);
- out.push_str("]\n");
-}
-
-/// Append `value` to `out` as a quoted JSON string. Hand-rolled rather than
-/// taking on a JSON crate for this one call site: escape what JSON requires
-/// (`"`, `\`, and the C0 control codes) and pass the rest — already valid
-/// UTF-8, since it came from `String::from_utf8_lossy` — straight through.
-fn push_json_string(value: &str, out: &mut String) {
- out.push('"');
- for ch in value.chars() {
- match ch {
- '"' => out.push_str("\\\""),
- '\\' => out.push_str("\\\\"),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
- c => out.push(c),
- }
- }
- out.push('"');
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "unit test")]
-
- use super::*;
-
- // @relation(checks.post-receive, role=Verifies)
- #[test]
- fn parse_updates_keeps_content_branches_only() {
- let new = "1111111111111111111111111111111111111111";
- let zero = "0".repeat(40);
- let input = format!(
- "{zero} {new} refs/heads/main\n\
- {new} {zero} refs/heads/old\n\
- {new} {new} refs/meta/effects/fmt\n\
- {new} {new} refs/heads/feature\n",
- );
- let updates = parse_updates(&input);
- let refs: Vec<&str> = updates.iter().map(|u| u.ref_name).collect();
- assert_eq!(refs, vec!["refs/heads/main", "refs/heads/feature"]);
- }
-
- // @relation(checks.post-receive, role=Verifies)
- #[test]
- fn triggers_effects_hard_excludes_results_and_index_regardless_of_pattern() {
- // A `refs/*`-broad trigger must never fire on a push under
- // `refs/meta/results/*` or `refs/meta/index/*` — the exclusion is
- // independent of how permissive an effect's own trigger pattern is.
- assert!(!triggers_effects("refs/meta/results/fmt/abc123"));
- assert!(!triggers_effects("refs/meta/index/abc123"));
- // Ordinary content refs are unaffected.
- assert!(triggers_effects("refs/heads/main"));
- assert!(triggers_effects("refs/meta/effects/fmt"));
- }
-
- // @relation(checks.post-receive, role=Verifies)
- #[test]
- fn parse_updates_never_enqueues_a_results_ref_push() {
- let new = "1".repeat(40);
- let old = "0".repeat(40);
- let input = format!(
- "{old} {new} refs/meta/results/fmt/abcdef123456\n{old} {new} refs/heads/main\n"
- );
- let updates = parse_updates(&input);
- let refs: Vec<&str> = updates.iter().map(|u| u.ref_name).collect();
- assert_eq!(refs, vec!["refs/heads/main"]);
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn activate_leaves_a_toolchain_free_command_unchanged() {
- let dirs = HashMap::new();
- assert_eq!(activate("cargo test", &[], &dirs), "cargo test");
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn activate_prefixes_path_in_declared_order() {
- let mut dirs = HashMap::new();
- dirs.insert("gcc".to_owned(), "/toolchains/aaa".to_owned());
- dirs.insert("cmake".to_owned(), "/toolchains/bbb".to_owned());
- let toolchains = vec!["gcc".to_owned(), "cmake".to_owned()];
- assert_eq!(
- activate("make", &toolchains, &dirs),
- "export PATH=/toolchains/aaa:/toolchains/bbb:$PATH; make"
- );
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn activate_skips_a_toolchain_missing_from_dirs() {
- let dirs = HashMap::new();
- let toolchains = vec!["gcc".to_owned()];
- assert_eq!(
- activate("make", &toolchains, &dirs),
- "export PATH=:$PATH; make"
- );
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn downloaded_script_extracts_each_component_per_its_layout() {
- let components = vec![
- git_toolchain::Component {
- url: "https://static.rust-lang.org/dist/rustc.tar.gz".to_owned(),
- sha256: "aaa".to_owned(),
- strip: 2,
- dest: String::new(),
- },
- git_toolchain::Component {
- url: "https://example.com/flat.tar.xz".to_owned(),
- sha256: "bbb".to_owned(),
- strip: 1,
- dest: "bin".to_owned(),
- },
- ];
- assert_eq!(
- downloaded_script("/toolchains/key", &components),
- "rm -rf /toolchains/key.tmp \
- && mkdir -p /toolchains/key.tmp \
- && mkdir -p /toolchains/key.tmp \
- && curl -fsSL 'https://static.rust-lang.org/dist/rustc.tar.gz' -o /tmp/component.archive \
- && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = 'aaa' ] \
- && tar -x --strip-components=2 -C /toolchains/key.tmp -f /tmp/component.archive \
- && rm -f /tmp/component.archive \
- && mkdir -p /toolchains/key.tmp/bin \
- && curl -fsSL 'https://example.com/flat.tar.xz' -o /tmp/component.archive \
- && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = 'bbb' ] \
- && tar -x --strip-components=1 -C /toolchains/key.tmp/bin -f /tmp/component.archive \
- && rm -f /tmp/component.archive \
- && rm -rf /toolchains/key \
- && mv /toolchains/key.tmp /toolchains/key"
- );
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn unpack_script_kills_stale_processes_before_the_wipe() {
- let script = unpack_script();
- let kill = script.find("kill -9").unwrap();
- let wipe = script.find("rm -rf").unwrap();
- assert!(kill < wipe);
- assert!(script.ends_with("rm -rf /work && mkdir -p /work && tar -x -C /work"));
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn components_key_includes_the_layout() {
- let component = git_toolchain::Component {
- url: "https://example.com/a.tar.gz".to_owned(),
- sha256: "aaa".to_owned(),
- strip: 2,
- dest: String::new(),
- };
- let mut flat = component.clone();
- flat.strip = 1;
- flat.dest = "bin".to_owned();
- assert_eq!(components_key(std::slice::from_ref(&component)), "aaa.2.");
- assert_ne!(
- components_key(std::slice::from_ref(&component)),
- components_key(&[flat])
- );
- }
-
- // @relation(checks.cache, role=Verifies)
- #[test]
- fn with_cache_env_leaves_a_cache_free_command_unchanged() {
- assert_eq!(with_cache_env("cargo build", None), "cargo build");
- }
-
- // @relation(checks.cache, role=Verifies)
- #[test]
- fn with_cache_env_exports_the_restored_directory() {
- assert_eq!(
- with_cache_env("cargo build", Some("/cache/sccache")),
- "export EFFECT_CACHE_DIR=/cache/sccache; cargo build"
- );
- }
-
- // @relation(checks.worker, role=Verifies)
- #[test]
- fn composite_status_derives_from_its_dependencies() {
- assert_eq!(
- derive_composite(&[Status::Pass, Status::Pass]),
- Status::Pass
- );
- assert_eq!(
- derive_composite(&[Status::Pass, Status::Fail]),
- Status::Fail
- );
- assert_eq!(
- derive_composite(&[Status::Error, Status::Skipped]),
- Status::Fail
- );
- assert_eq!(
- derive_composite(&[Status::Pass, Status::Skipped]),
- Status::Skipped
- );
- // Vacuously all-pass: a composite with no dependencies never validates,
- // but the derivation itself is total.
- assert_eq!(derive_composite(&[]), Status::Pass);
- }
-
- // @relation(checks.worker, role=Verifies)
- #[test]
- fn pending_jobs_groups_by_repo_and_drops_malformed() {
- let queue = tempfile::tempdir().unwrap();
- let write = |name: &str, body: &str| {
- std::fs::write(queue.path().join(name), body).unwrap();
- };
- let oid_a = "a".repeat(40);
- let oid_b = "b".repeat(40);
- let oid_c = "c".repeat(40);
- let oid_d = "d".repeat(40);
- write("a.job", &format!("/repos/one\n{oid_a}\nrefs/heads/main\n"));
- write("b.job", &format!("/repos/one\n{oid_b}\nrefs/heads/dev\n"));
- write("c.job", &format!("/repos/two\n{oid_c}\nrefs/heads/main\n"));
- write("d.job", "garbage\n");
- write(
- "ignored.tmp",
- &format!("/repos/one\n{oid_d}\nrefs/heads/main\n"),
- );
-
- let groups = pending_jobs(queue.path());
- assert_eq!(groups.len(), 2);
- assert_eq!(groups[&PathBuf::from("/repos/one")].len(), 2);
- assert_eq!(groups[&PathBuf::from("/repos/two")].len(), 1);
- // The malformed job is dropped from the queue, the .tmp left untouched.
- assert!(!queue.path().join("d.job").exists());
- assert!(queue.path().join("ignored.tmp").exists());
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn backend_for_token_is_docker_without_a_token() {
- assert_eq!(backend_for_token(None), BackendKind::Docker);
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn backend_for_token_is_sprite_with_a_token() {
- assert_eq!(backend_for_token(Some("test-token")), BackendKind::Sprite);
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- #[cfg_attr(
- windows,
- ignore = "windows runners use Windows containers; no Linux image support"
- )]
- fn docker_backend_runs_a_trivial_effect() {
- if docker::ensure_docker().is_err() {
- eprintln!("skipping docker_backend_runs_a_trivial_effect: docker is not available");
- return;
- }
-
- let repo = crate::testutil::unique_repo("docker-run");
- crate::testutil::write_effect_doc(&repo, "hello", "echo hi-from-docker");
- let status = Command::new("git")
- .arg("-C")
- .arg(&repo)
- .args(["commit", "--allow-empty", "-q", "-m", "seed"])
- .status()
- .unwrap();
- assert!(status.success());
- let head = Command::new("git")
- .arg("-C")
- .arg(&repo)
- .args(["rev-parse", "HEAD"])
- .output()
- .unwrap();
- assert!(head.status.success());
- let head = String::from_utf8(head.stdout).unwrap();
-
- let live = new_live_registry();
- let outcomes = run_effect_at(&repo, head.trim(), BackendKind::Docker, &live).unwrap();
- let outcome = outcomes
- .iter()
- .find(|outcome| outcome.name == "hello")
- .unwrap();
- assert_eq!(outcome.status, Status::Pass);
- assert!(
- outcome
- .recording
- .as_deref()
- .unwrap_or_default()
- .contains("hi-from-docker")
- );
- }
-}
crates/git-effect/src/executor.rs
@@ -1,237 +1,0 @@
-//! `exec-local`: [`git_backend::EffectExecutor`] over this crate's existing
-//! Docker-sandboxed local backend — the `exec-local` row of
-//! `docs/scale-out.adoc`'s "EffectExecutor" table (WS7).
-//!
-//! A thin adapter, not a second execution path: the tree checkout goes
-//! through [`local::sync_tree`], toolchains through the same
-//! [`git_toolchain::export`] call every local backend uses (via
-//! [`local::export_toolchains`]), caches through [`cache::restore_local`] /
-//! [`cache::snapshot_local`], and the run itself through the engine's own
-//! Docker backend. That is correctness rule 6 ("materialization is one code
-//! path") applied to the local/remote split: `exec-local` and a
-//! push-triggered engine run may differ in who orchestrates them, never in
-//! the code that materializes and runs an effect.
-
-use std::collections::HashMap;
-use std::path::PathBuf;
-use std::sync::{Arc, Mutex as StdMutex, PoisonError};
-use std::thread::JoinHandle;
-
-use git_backend::{
- EffectDef, EffectExecutor, EffectHandle, EffectStatus, Error, MaterializedInputs,
-};
-
-use crate::results::Status;
-use crate::{cache, docker, engine, local};
-
-/// [`EffectExecutor`] running each effect in a throwaway local Docker
-/// container (see [`crate::docker`]), materialized from one repository.
-/// `spawn` prepares the sandbox and hands the run to a worker thread;
-/// `wait` joins it.
-///
-/// ## Requirements
-///
-/// @relation(checks.sandbox)
-pub struct LocalExecutor {
- repo: PathBuf,
- running: StdMutex<HashMap<String, JoinHandle<Status>>>,
-}
-
-impl LocalExecutor {
- /// An executor materializing effects from (and snapshotting caches back
- /// to) `repo`.
- #[must_use]
- pub fn new(repo: impl Into<PathBuf>) -> Self {
- Self {
- repo: repo.into(),
- running: StdMutex::new(HashMap::new()),
- }
- }
-}
-
-/// Lock `mutex`, recovering the guard from a poisoned lock rather than
-/// panicking — same rationale as `engine::lock`: a lost worker entry is
-/// worth an error result, never a torn-down process.
-fn lock<T>(mutex: &StdMutex<T>) -> std::sync::MutexGuard<'_, T> {
- mutex.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-impl EffectExecutor for LocalExecutor {
- fn spawn(
- &self,
- effect: &EffectDef,
- inputs: MaterializedInputs,
- ) -> git_backend::Result<EffectHandle> {
- let Some(command) = effect.command.clone() else {
- return Err(Error::Effect(format!(
- "effect {} is composite (no command); its outcome derives from its \
- dependencies instead of a spawn",
- effect.name
- )));
- };
- docker::ensure_docker().map_err(Error::Effect)?;
- let sandbox = local::Sandbox::new().map_err(Error::Effect)?;
- local::sync_tree(&self.repo, &sandbox, inputs.tree).map_err(Error::Effect)?;
-
- // The map's keys name the toolchains to materialize; the PATH
- // entries activated in-container are derived from the Docker
- // backend's own bind-mount layout, exactly as
- // `engine::Backend::resolve_toolchains` derives them per backend —
- // a caller-resolved entry describes some other context's
- // filesystem, which this container never sees.
- let names: Vec<String> = inputs.toolchain_paths.keys().cloned().collect();
- local::export_toolchains(&self.repo, &sandbox, &names).map_err(Error::Effect)?;
- let dirs: HashMap<String, String> = names
- .iter()
- .map(|name| {
- (
- name.clone(),
- format!("{}/{name}/bin", docker::TOOLCHAINS_DIR),
- )
- })
- .collect();
- let mut command = engine::activate(&command, &names, &dirs);
-
- if let Some(name) = &inputs.cache {
- cache::restore_local(&self.repo, &sandbox.cache_dir(name), name)
- .map_err(Error::Effect)?;
- command =
- engine::with_cache_env(&command, Some(&format!("{}/{name}", docker::CACHE_DIR)));
- }
-
- let id = uuid::Uuid::new_v4().to_string();
- let repo = self.repo.clone();
- let name = effect.name.clone();
- let cache_name = inputs.cache.clone();
- let worker = std::thread::spawn(move || {
- let live = Arc::new(StdMutex::new(String::new()));
- let result = engine::run_one_docker(&sandbox, &name, &command, &live);
- if let Some(cache_name) = cache_name
- && let Err(e) =
- cache::snapshot_local(&repo, &sandbox.cache_dir(&cache_name), &cache_name)
- {
- eprintln!("effects: could not snapshot cache {cache_name}: {e}");
- }
- result.status
- });
- lock(&self.running).insert(id.clone(), worker);
- Ok(EffectHandle { id })
- }
-
- fn wait(&self, handle: &EffectHandle) -> git_backend::Result<EffectStatus> {
- let Some(worker) = lock(&self.running).remove(&handle.id) else {
- return Err(Error::Effect(format!(
- "unknown effect handle {}",
- handle.id
- )));
- };
- let status = match worker.join() {
- Ok(status) => status,
- Err(_panic) => {
- return Err(Error::Effect(
- "the effect's worker thread panicked".to_owned(),
- ));
- }
- };
- Ok(match status {
- Status::Pass => EffectStatus::Pass,
- Status::Fail => EffectStatus::Fail,
- // `run_one_docker` only settles Pass/Fail/Error; the queued /
- // running / skipped states are engine bookkeeping it never
- // returns.
- _ => EffectStatus::Error,
- })
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::assertions_on_result_states,
- reason = "unit test"
- )]
-
- use std::collections::BTreeMap;
- use std::process::Command;
-
- use gix_hash::ObjectId;
-
- use super::*;
-
- fn inputs(tree: ObjectId) -> MaterializedInputs {
- MaterializedInputs {
- tree,
- toolchain_paths: BTreeMap::new(),
- cache: None,
- }
- }
-
- fn zero_tree() -> ObjectId {
- ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap()
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn a_composite_effect_is_never_spawned() {
- let executor = LocalExecutor::new("/nonexistent");
- let effect = EffectDef {
- name: "all".to_owned(),
- command: None,
- image: None,
- };
- assert!(executor.spawn(&effect, inputs(zero_tree())).is_err());
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn waiting_on_an_unknown_handle_is_an_error() {
- let executor = LocalExecutor::new("/nonexistent");
- let handle = EffectHandle {
- id: "no-such-run".to_owned(),
- };
- assert!(executor.wait(&handle).is_err());
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- #[cfg_attr(
- windows,
- ignore = "windows runners use Windows containers; no Linux image support"
- )]
- fn local_executor_runs_a_trivial_effect() {
- if docker::ensure_docker().is_err() {
- eprintln!("skipping local_executor_runs_a_trivial_effect: docker is not available");
- return;
- }
-
- let repo = crate::testutil::unique_repo("exec-local");
- let status = Command::new("git")
- .arg("-C")
- .arg(&repo)
- .args(["commit", "--allow-empty", "-q", "-m", "seed"])
- .status()
- .unwrap();
- assert!(status.success());
- let tree = Command::new("git")
- .arg("-C")
- .arg(&repo)
- .args(["rev-parse", "HEAD^{tree}"])
- .output()
- .unwrap();
- assert!(tree.status.success());
- let tree =
- ObjectId::from_hex(String::from_utf8(tree.stdout).unwrap().trim().as_bytes()).unwrap();
-
- let executor = LocalExecutor::new(&repo);
- let effect = EffectDef {
- name: "hello".to_owned(),
- command: Some("echo hi-from-exec-local".to_owned()),
- image: None,
- };
- let handle = executor.spawn(&effect, inputs(tree)).unwrap();
- assert_eq!(executor.wait(&handle).unwrap(), EffectStatus::Pass);
- // The handle's backend-side state is consumed by the first wait.
- assert!(executor.wait(&handle).is_err());
- }
-}
crates/git-effect/src/lib.rs
@@ -1,33 +1,0 @@
-//! The Effect abstraction: anything a server runs against a push (CI, CD,
-//! linting, versioning gates), decomposed into two pieces.
-//!
-//! [`definition`] holds an effect's static shape — its command, dependencies,
-//! toolchains, and cache, one ref per effect at `refs/meta/effects/<name>`.
-//! [`results`] holds what running an effect against a commit produced, one
-//! ref per effect per commit at `refs/meta/results/<effect>/<short-oid>`.
-//! [`engine`] runs the effects a `post-receive` hook queues, in a Sprite
-//! sandbox, and records their outcomes through `results`. [`cache`] persists
-//! a read-write cache directory an effect's command can build up across runs.
-//! [`executor`] adapts the Docker backend to [`git_backend::EffectExecutor`]
-//! — `exec-local`, the executor seam's local half (`docs/scale-out.adoc`,
-//! WS7).
-//!
-//! This crate used to be `checks` (definitions in `git-ents-core`, execution
-//! in `git-ents-server`) — see each module's migration note for the storage
-//! rename that came with the split into its own crate.
-
-pub mod cache;
-pub mod definition;
-pub mod docker;
-pub mod engine;
-pub mod executor;
-pub mod local;
-pub mod results;
-mod stream;
-#[cfg(test)]
-mod testutil;
-
-pub use cache::{CACHE_NS, cache_dir, cache_ref};
-pub use definition::{EFFECTS_NS, Effect, effect_ref, load, load_all, order, store};
-pub use executor::LocalExecutor;
-pub use results::{CommitRuns, RESULTS_NS, Run, RunOutcome, Status, record, runs, update_run};
crates/git-effect/src/local.rs
@@ -1,174 +1,0 @@
-//! Host-side materialization shared by every *local* effect backend (Docker,
-//! and host-direct/`--unsandboxed`) — the Fly.io Sprite backend
-//! ([`crate::engine`]) instead streams bytes into the Sprite's own
-//! filesystem, since there is no host directory to bind-mount there.
-//!
-//! A [`Sandbox`] is one effect run's scratch area: a fresh temp directory
-//! holding the checked-out tree (`work`), every declared toolchain's
-//! extracted `bin` (`toolchains/<name>`), and every declared cache
-//! (`cache/<name>`) — laid out on the *host* filesystem so the Docker backend
-//! can bind-mount it straight into the container, and host-direct execution
-//! can just point `PATH`/`$PWD` at it. Toolchain extraction goes through
-//! [`git_toolchain::export`], the same function the Sprite path's own doc
-//! comments call out as its local/hosted parity anchor, so a toolchain's
-//! materialized bytes are identical no matter which backend runs it.
-
-use std::collections::HashMap;
-use std::path::{Path, PathBuf};
-
-use gix_hash::ObjectId;
-use std::process::Command;
-
-use crate::definition::Effect;
-
-/// One effect run's host-side scratch area, torn down when dropped.
-pub struct Sandbox {
- root: tempfile::TempDir,
-}
-
-impl Sandbox {
- /// A fresh sandbox with empty `work`/`toolchains`/`cache` directories.
- pub fn new() -> Result<Self, String> {
- let root = tempfile::tempdir().map_err(|e| format!("could not create scratch dir: {e}"))?;
- for name in ["work", "toolchains", "cache"] {
- std::fs::create_dir_all(root.path().join(name))
- .map_err(|e| format!("could not create {name} dir: {e}"))?;
- }
- Ok(Self { root })
- }
-
- /// The checked-out tree's directory.
- #[must_use]
- pub fn work_dir(&self) -> PathBuf {
- self.root.path().join("work")
- }
-
- /// The parent of every extracted toolchain's `<name>` directory.
- #[must_use]
- pub fn toolchains_dir(&self) -> PathBuf {
- self.root.path().join("toolchains")
- }
-
- /// The parent of every restored cache's `<name>` directory.
- #[must_use]
- pub fn cache_root(&self) -> PathBuf {
- self.root.path().join("cache")
- }
-
- /// Where cache `name` is restored, created even absent a prior snapshot
- /// so a tool populating it fresh always finds it there.
- #[must_use]
- pub fn cache_dir(&self, name: &str) -> PathBuf {
- self.cache_root().join(name)
- }
-}
-
-/// Replace the sandbox's [`Sandbox::work_dir`] with the tree at `new`, via
-/// `git archive | tar -x` straight onto the host filesystem — no sandbox CLI
-/// involved, unlike the Sprite path's streamed unpack.
-pub fn sync_tree(repo: &Path, sandbox: &Sandbox, new: ObjectId) -> Result<(), String> {
- let mut archive = Command::new("git");
- archive
- .arg("-C")
- .arg(repo)
- .args(["archive", "--format=tar", &new.to_string()]);
- let mut extract = Command::new("tar");
- extract.args(["-x", "-C"]).arg(sandbox.work_dir());
- crate::stream::pipe(archive, extract, &format!("syncing the tree at {new}"))
-}
-
-/// Resolve and extract every distinct toolchain named across `runnable` into
-/// `sandbox.toolchains_dir()/<name>/bin` via [`git_toolchain::export`],
-/// returning the resolved (deduplicated) names — the exported bytes are
-/// identical regardless of whether `bin` is [`git_toolchain::Bin::Embedded`]
-/// or [`git_toolchain::Bin::Downloaded`], since `export` normalizes both to
-/// the same `<dest>/bin/…` shape.
-pub fn resolve_toolchains(
- repo: &Path,
- sandbox: &Sandbox,
- runnable: &[Effect],
-) -> Result<Vec<String>, String> {
- let mut names: Vec<String> = runnable
- .iter()
- .flat_map(|effect| effect.toolchains.iter().cloned())
- .collect();
- names.sort_unstable();
- names.dedup();
- export_toolchains(repo, sandbox, &names)?;
- Ok(names)
-}
-
-/// Extract each of `names` into `sandbox.toolchains_dir()/<name>` via
-/// [`git_toolchain::export`], once — a name already exported into this
-/// sandbox is left alone. The extraction half of [`resolve_toolchains`],
-/// shared with the `exec-local` executor ([`crate::executor`]), which
-/// receives its toolchain names pre-resolved rather than reading them off
-/// an [`Effect`].
-pub fn export_toolchains(repo: &Path, sandbox: &Sandbox, names: &[String]) -> Result<(), String> {
- for name in names {
- let dest = sandbox.toolchains_dir().join(name);
- if dest.exists() {
- continue;
- }
- git_toolchain::export(repo, name, &dest)
- .map_err(|e| format!("could not resolve toolchain {name}: {e}"))?;
- }
- Ok(())
-}
-
-/// The container/host-relative path a toolchain named `name` was exported to
-/// (see [`resolve_toolchains`]), for building an `activate()` `PATH`.
-#[must_use]
-pub fn toolchain_bin_dir(sandbox: &Sandbox, name: &str) -> PathBuf {
- sandbox.toolchains_dir().join(name).join("bin")
-}
-
-/// A `name -> bin dir` map from `names`, each pointing at its host path under
-/// `sandbox` — used by host-direct execution, which runs outside any
-/// container and so needs the real host path rather than a bind-mounted
-/// in-container one.
-#[must_use]
-pub fn host_toolchain_dirs(sandbox: &Sandbox, names: &[String]) -> HashMap<String, String> {
- names
- .iter()
- .map(|name| {
- (
- name.clone(),
- toolchain_bin_dir(sandbox, name).display().to_string(),
- )
- })
- .collect()
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "unit test")]
-
- use super::*;
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn sandbox_starts_with_empty_work_toolchains_cache() {
- let sandbox = Sandbox::new().unwrap();
- assert!(sandbox.work_dir().is_dir());
- assert!(sandbox.toolchains_dir().is_dir());
- assert!(sandbox.cache_root().is_dir());
- }
-
- // @relation(checks.sandbox, role=Verifies)
- #[test]
- fn host_toolchain_dirs_map_to_the_sandbox_bin_directory() {
- let sandbox = Sandbox::new().unwrap();
- let names = vec!["gcc".to_owned()];
- let dirs = host_toolchain_dirs(&sandbox, &names);
- assert_eq!(
- dirs["gcc"],
- sandbox
- .toolchains_dir()
- .join("gcc")
- .join("bin")
- .display()
- .to_string()
- );
- }
-}
crates/git-effect/src/results.rs
@@ -1,395 +1,0 @@
-//! Recorded effect runs, sourced from `refs/meta/results/<effect>/<short-oid>`
-//! — one ref per effect, per checked commit.
-//!
-//! # Migration note
-//!
-//! Results were runs: `refs/meta/runs/<commit>` (one ref per commit, a
-//! scalar-keyed map of every check's outcome) decomposed to
-//! `refs/meta/results/<effect>/<short-oid>` (one ref per effect per commit),
-//! matching [`crate::definition`]'s checks→effects decomposition. The public
-//! [`CommitRuns`]/[`Run`]/[`RunOutcome`] shape stays the aggregate view a
-//! caller wants — every effect's outcome against a commit, grouped by the
-//! moment they were recorded — reassembled in [`runs`] from the decomposed
-//! refs rather than read directly off one ref. Incompatible with data written
-//! in the prior layout — acceptable pre-1.0 (see the format compatibility
-//! rules in `git_store`'s module docs).
-
-use std::path::Path;
-
-use facet::Facet;
-use gix_hash::ObjectId;
-
-/// The ref namespace under which effect runs are recorded: one ref,
-/// `refs/meta/results/<effect>/<short-oid>`, per effect per checked commit,
-/// holding the *log* of every run of that effect against that commit.
-/// Definitions live under [`crate::definition::EFFECTS_NS`]; this is their
-/// history.
-pub const RESULTS_NS: &str = "refs/meta/results";
-
-/// How many hex characters of the checked commit's id the ref's last segment
-/// carries. The full id is also stored in the document body (see
-/// [`ResultBody::commit`]), so truncation here is purely a naming
-/// convenience, not a loss of precision.
-const SHORT_LEN: usize = 12;
-
-/// An effect run's status, progressing `Queued` → `Running` → a terminal
-/// outcome. Closed set — the only values a run legitimately takes, in place
-/// of a `String` that every caller had to trust held one of five values.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
-#[repr(u8)]
-pub enum Status {
- /// Enqueued by `post-receive`, not yet picked up by the worker.
- Queued,
- /// The worker has started this run.
- Running,
- /// The effect exited successfully.
- Pass,
- /// The effect exited with a failure.
- Fail,
- /// An infrastructure failure (an unreachable sandbox, a timeout) kept the
- /// effect from completing.
- Error,
- /// The effect never ran because a dependency did not pass.
- Skipped,
-}
-
-impl std::fmt::Display for Status {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(match self {
- Self::Queued => "queued",
- Self::Running => "running",
- Self::Pass => "pass",
- Self::Fail => "fail",
- Self::Error => "error",
- Self::Skipped => "skipped",
- })
- }
-}
-
-/// One effect run's on-disk body, at `refs/meta/results/<effect>/<short-oid>`.
-/// The checked commit's full id is carried here (not just abbreviated in the
-/// ref name) so [`runs`] can recover it exactly regardless of [`SHORT_LEN`].
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct ResultBody {
- /// The checked commit's full hex id.
- commit: String,
- /// `queued`, `running`, then `pass`, `fail`, or `error`.
- status: Status,
- /// How long the effect took to run, when known.
- duration_secs: Option<u64>,
- /// The effect's terminal session, captured as asciicast v2 (JSONL) text,
- /// when the runner recorded one.
- recording: Option<String>,
- /// The command's process exit code, when the effect ran to completion
- /// rather than erroring out before or during execution (an unreachable
- /// sandbox, a timeout).
- exit_code: Option<i32>,
-}
-
-/// One effect's outcome, independent of which commit or moment it was
-/// recorded for.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct RunOutcome {
- /// The effect's name (its `refs/meta/effects/<name>`).
- pub name: String,
- /// The outcome recorded for it as a run progresses.
- pub status: Status,
- /// How long the effect took to run, when known.
- pub duration_secs: Option<u64>,
- /// The effect's terminal session, captured as asciicast v2 (JSONL) text,
- /// when the runner recorded one.
- pub recording: Option<String>,
- /// The command's process exit code, when the effect ran to completion
- /// rather than erroring out before or during execution (an unreachable
- /// sandbox, a timeout).
- pub exit_code: Option<i32>,
-}
-
-/// One recorded execution of the effect set against a commit — every effect's
-/// outcome recorded at the same moment, reassembled from their independent
-/// per-effect refs (see the module's migration note).
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Run {
- /// When the run was recorded, as seconds since the Unix epoch — the
- /// underlying commits' committer date.
- pub at: u64,
- /// Each effect's outcome recorded at `at`, in name order.
- pub results: Vec<RunOutcome>,
-}
-
-/// The runs recorded for one commit: its object id and every execution
-/// against it, newest first.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CommitRuns {
- /// The checked commit's object id.
- pub commit: ObjectId,
- /// Every run against it, newest first.
- pub runs: Vec<Run>,
-}
-
-/// The ref holding effect `effect`'s run history against `commit`.
-fn result_ref(effect: &str, commit: ObjectId) -> String {
- format!(
- "{RESULTS_NS}/{effect}/{}",
- commit.to_hex_with_len(SHORT_LEN)
- )
-}
-
-/// Record a run of `outcomes` against `commit` in `repo`: each effect's
-/// outcome becomes a new commit on its own `result_ref`, parented on that
-/// effect's prior run, so each effect's ref accrues its own history. Not
-/// atomic across effects — an effect's own ref is the unit of consistency
-/// here, the same one-ref-per-entity trade-off [`crate::definition`] makes.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-pub fn record(
- repo: &Path,
- commit: ObjectId,
- outcomes: &[RunOutcome],
-) -> Result<(), git_store::Error> {
- let store = git_store::Store::open(repo)?;
- for outcome in outcomes {
- let body = to_body(commit, outcome);
- store.store(
- &result_ref(&outcome.name, commit),
- &body,
- "Record effect run",
- )?;
- }
- Ok(())
-}
-
-/// Advance the latest run recorded for each of `outcomes`' effects against
-/// `commit`, in place. Unlike [`record`], which appends a new run per effect,
-/// this replaces each effect's run ref tip (re-parented on its prior parents)
-/// so a single run's status can progress — `queued` → `running` → results —
-/// without appending a commit per transition.
-///
-/// When no run has been recorded yet for an effect the update starts one, so
-/// a worker that advances a run is self-healing even if the `queued` record
-/// never landed.
-///
-/// ## Requirements
-///
-/// @relation(checks.outcomes)
-pub fn update_run(
- repo: &Path,
- commit: ObjectId,
- outcomes: &[RunOutcome],
-) -> Result<(), git_store::Error> {
- let store = git_store::Store::open(repo)?;
- for outcome in outcomes {
- let body = to_body(commit, outcome);
- store.amend(
- &result_ref(&outcome.name, commit),
- &body,
- "Record effect run",
- )?;
- }
- Ok(())
-}
-
-/// List the recorded runs per commit in `repo`, newest commit first. Every
-/// effect's run history under [`RESULTS_NS`] is read and grouped by checked
-/// commit, then by the recorded moment (`at`), so effects updated together
-/// (as the worker always does — see [`update_run`]) reassemble into one
-/// [`Run`] with every effect's outcome, matching the pre-decomposition shape.
-///
-/// A ref whose path does not decompose into `<effect>/<short-oid>`, or whose
-/// commit segment is not a valid hex object id, cannot have been written by
-/// [`record`]/[`update_run`], so it is skipped rather than surfaced as an
-/// error.
-pub fn runs(repo: &Path) -> Result<Vec<CommitRuns>, git_store::Error> {
- let store = git_store::Store::open(repo)?;
- let prefix = format!("{RESULTS_NS}/");
- let mut by_commit: std::collections::BTreeMap<
- ObjectId,
- std::collections::BTreeMap<u64, Vec<RunOutcome>>,
- > = std::collections::BTreeMap::new();
- for refname in store.list(&prefix)? {
- let Some(rest) = refname.strip_prefix(&prefix) else {
- continue;
- };
- let Some((effect, _short_oid)) = rest.split_once('/') else {
- continue;
- };
- for (at, body) in store.history::<ResultBody>(&refname)? {
- let Some(commit) = ObjectId::from_hex(body.commit.as_bytes()).ok() else {
- continue;
- };
- by_commit
- .entry(commit)
- .or_default()
- .entry(at)
- .or_default()
- .push(from_body(effect.to_owned(), body));
- }
- }
-
- let mut commits: Vec<CommitRuns> = by_commit
- .into_iter()
- .map(|(commit, by_at)| {
- let mut runs: Vec<Run> = by_at
- .into_iter()
- .map(|(at, mut results)| {
- results.sort_by(|a, b| a.name.cmp(&b.name));
- Run { at, results }
- })
- .collect();
- runs.sort_by_key(|run| std::cmp::Reverse(run.at));
- CommitRuns { commit, runs }
- })
- .collect();
- commits.sort_by(|a, b| {
- let a_at = a.runs.first().map_or(0, |run| run.at);
- let b_at = b.runs.first().map_or(0, |run| run.at);
- b_at.cmp(&a_at)
- });
- Ok(commits)
-}
-
-/// Build a [`ResultBody`] from a public [`RunOutcome`] for `commit`.
-fn to_body(commit: ObjectId, outcome: &RunOutcome) -> ResultBody {
- ResultBody {
- commit: commit.to_string(),
- status: outcome.status,
- duration_secs: outcome.duration_secs,
- recording: outcome.recording.clone(),
- exit_code: outcome.exit_code,
- }
-}
-
-/// Assemble a public [`RunOutcome`] named `name` from its on-disk [`ResultBody`].
-fn from_body(name: String, body: ResultBody) -> RunOutcome {
- RunOutcome {
- name,
- status: body.status,
- duration_secs: body.duration_secs,
- recording: body.recording,
- exit_code: body.exit_code,
- }
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::indexing_slicing,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_result_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("results")
- }
-
- fn outcome(name: &str, status: Status) -> RunOutcome {
- RunOutcome {
- name: name.to_owned(),
- status,
- duration_secs: None,
- recording: None,
- exit_code: None,
- }
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn record_then_runs_round_trips_a_run() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- record(
- &repo,
- commit,
- &[outcome("fmt", Status::Pass), outcome("test", Status::Fail)],
- )
- .unwrap();
-
- let commits = runs(&repo).unwrap();
- assert_eq!(commits.len(), 1);
- assert_eq!(commits[0].commit, commit);
- assert_eq!(commits[0].runs.len(), 1);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass), outcome("test", Status::Fail)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn update_run_advances_in_place_rather_than_appending() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- record(&repo, commit, &[outcome("fmt", Status::Queued)]).unwrap();
- update_run(&repo, commit, &[outcome("fmt", Status::Running)]).unwrap();
- update_run(&repo, commit, &[outcome("fmt", Status::Pass)]).unwrap();
- let commits = runs(&repo).unwrap();
- assert_eq!(commits[0].runs.len(), 1);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn empty_when_no_runs_recorded() {
- let repo = unique_repo();
- assert!(runs(&repo).unwrap().is_empty());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn round_trips_an_outcomes_duration_and_recording() {
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- let rich = outcome("fmt", Status::Pass);
- let rich = RunOutcome {
- duration_secs: Some(12),
- recording: Some("{\"version\": 2}\n[0.5, \"o\", \"hi\\r\\n\"]\n".to_owned()),
- exit_code: Some(0),
- ..rich
- };
- record(&repo, commit, std::slice::from_ref(&rich)).unwrap();
- let commits = runs(&repo).unwrap();
- assert_eq!(commits[0].runs[0].results, vec![rich]);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(checks.outcomes, role=Verifies)
- #[test]
- fn displays_lowercase_status_words() {
- assert_eq!(Status::Queued.to_string(), "queued");
- assert_eq!(Status::Pass.to_string(), "pass");
- assert_eq!(Status::Skipped.to_string(), "skipped");
- }
-
- #[test]
- fn loads_the_on_disk_result_format() {
- // A fixture written as the real `status/<Variant>` subtree layout,
- // with `duration_secs`/`recording` omitted, must keep loading, with
- // the missing optional fields unset.
- let repo = unique_repo();
- let commit = ObjectId::from_hex(b"0123456789012345678901234567890123456789").unwrap();
- write_result_doc(&repo, "fmt", commit, "Pass");
- let commits = runs(&repo).unwrap();
- assert_eq!(commits.len(), 1);
- assert_eq!(commits[0].commit, commit);
- assert_eq!(
- commits[0].runs[0].results,
- vec![outcome("fmt", Status::Pass)]
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-}
crates/git-effect/src/stream.rs
@@ -1,41 +1,0 @@
-//! Kernel-side piping between two child processes.
-
-use std::process::{Command, Stdio};
-
-/// Run `producer | consumer` with the pipe handed to the kernel: the
-/// producer's stdout *is* the consumer's stdin, so the stream — a cache or
-/// tree archive that can outgrow the machine's memory — never lands in this
-/// process. Both exit statuses gate success: a producer that dies mid-stream
-/// fails the pipe even when the consumer accepted the truncated input.
-pub(crate) fn pipe(mut producer: Command, mut consumer: Command, what: &str) -> Result<(), String> {
- let producer_name = producer.get_program().to_string_lossy().into_owned();
- let consumer_name = consumer.get_program().to_string_lossy().into_owned();
- let mut producing = producer
- .stdin(Stdio::null())
- .stdout(Stdio::piped())
- .spawn()
- .map_err(|e| format!("could not run {producer_name} for {what}: {e}"))?;
- let Some(stdout) = producing.stdout.take() else {
- let _killed = producing.kill();
- let _reaped = producing.wait();
- return Err(format!("{producer_name} gave no stdout for {what}"));
- };
- let consumed = consumer.stdin(Stdio::from(stdout)).status();
- // Release this process's copy of the pipe's read end before waiting on
- // the producer: with the consumer dead mid-stream, the producer only
- // sees EPIPE — and stops blocking on a full pipe — once no read end is
- // left open.
- drop(consumer);
- let produced = producing
- .wait()
- .map_err(|e| format!("{producer_name} did not complete for {what}: {e}"))?;
- let consumed =
- consumed.map_err(|e| format!("could not run {consumer_name} for {what}: {e}"))?;
- if !produced.success() {
- return Err(format!("{producer_name} failed for {what}: {produced}"));
- }
- if !consumed.success() {
- return Err(format!("{consumer_name} failed for {what}: {consumed}"));
- }
- Ok(())
-}
crates/git-effect/src/testutil.rs
@@ -1,116 +1,0 @@
-//! Shared test helpers: a throwaway git repository and builders that lay an
-//! on-disk `refs/meta/*` document out with raw git plumbing.
-//!
-//! Building the tree directly — rather than through [`git_store::Store`] —
-//! pins the *on-disk* layout each document type promises: a load test against
-//! a fixture written this way fails the moment an incompatible change to a
-//! document's [`facet::Facet`] shape stops reading data already in the wild,
-//! the failure mode that broke every push once before.
-
-#![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "test support"
-)]
-
-use std::path::{Path, PathBuf};
-use std::process::Command;
-use std::sync::atomic::{AtomicUsize, Ordering};
-
-use gix_hash::ObjectId;
-
-use crate::definition::effect_ref;
-use crate::results::RESULTS_NS;
-
-/// A freshly initialized, uniquely named git repository under the temp dir.
-#[must_use]
-pub(crate) fn unique_repo(label: &str) -> PathBuf {
- static COUNTER: AtomicUsize = AtomicUsize::new(0);
- let n = COUNTER.fetch_add(1, Ordering::SeqCst);
- let dir = std::env::temp_dir().join(format!("git-effect-{label}-{}-{n}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- let status = Command::new("git")
- .arg("-C")
- .arg(&dir)
- .args(["init", "-q"])
- .status()
- .unwrap();
- assert!(status.success());
- for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test")] {
- let status = Command::new("git")
- .arg("-C")
- .arg(&dir)
- .args(["config", key, value])
- .status()
- .unwrap();
- assert!(status.success());
- }
- dir
-}
-
-/// Lay an effect document out at [`effect_ref`]`(name)` as the real on-disk
-/// format: a bare `command/some` blob (the `Option`-wrapped command), with
-/// the optional `image`/`depends`/`toolchains` fields omitted entirely.
-/// Asserts the loader fills a missing optional field as unset, independent
-/// of the writer.
-pub(crate) fn write_effect_doc(repo: &Path, name: &str, command: &str) {
- let command_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], command);
- let some_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("100644 blob {command_blob}\tsome\n"),
- );
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {some_tree}\tcommand\n"),
- );
- let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", &effect_ref(name), &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Lay a result document out at `refs/meta/results/<effect>/<commit>` as the
-/// real on-disk format: a `commit` blob (the checked commit's full hex id)
-/// and a `status/<variant>` subtree (the `Status` enum's unit variant
-/// resolving to an empty tree, exactly like `Member`'s `provenance`), with
-/// `duration_secs`/`recording`/`exit_code` omitted entirely — asserting the
-/// loader fills a result's missing optional fields as unset, independent of
-/// the writer. `variant` is the `Status` variant's name (`"Pass"`, `"Fail"`,
-/// …).
-pub(crate) fn write_result_doc(repo: &Path, effect: &str, commit: ObjectId, variant: &str) {
- let commit_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], &commit.to_string());
- let empty_tree = git_with_stdin(repo, &["mktree"], "");
- let variant_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {empty_tree}\t{variant}\n"),
- );
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {commit_blob}\tcommit\n\
- 040000 tree {variant_tree}\tstatus\n"
- ),
- );
- let refname = format!("{RESULTS_NS}/{effect}/{}", commit.to_hex_with_len(12));
- let tree_commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", &refname, &tree_commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Run git in `repo` with `input` on stdin, returning its trimmed stdout.
-fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String {
- git_store::test_support::git_with_stdin(repo, args, input)
-}
crates/git-ents-core/Cargo.toml
@@ -1,18 +1,0 @@
-[package]
-name = "git-ents-core"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-facet = { workspace = true }
-git-store = { workspace = true }
-gix = { workspace = true }
-thiserror = { workspace = true }
-
-[dev-dependencies]
-git-store = { workspace = true, features = ["test-support"] }
-
-[lints]
-workspace = true
crates/git-ents-core/src/account.rs
@@ -1,142 +1,0 @@
-//! The repository's account identity, sourced from the `refs/meta/account` ref.
-//!
-//! An *account* is just a repository that carries a `refs/meta/account` ref:
-//! identity is a repo, not a row in a central table. By convention an account
-//! repo is named `user/<username>`, but the trust never rests on that path —
-//! move the repo, keep the identity. The presence of the ref is what marks a
-//! repository as an account; its [`Account`] document carries the profile. This
-//! is the did:web-shaped identity the member refs will eventually `@`-mention.
-
-use std::path::Path;
-
-use facet::Facet;
-
-use git_store::component;
-
-// @relation(account.ref)
-/// The ref whose tree holds the account profile, and whose mere presence marks a
-/// repository as an account repo.
-pub const ACCOUNT_REF: &str = "refs/meta/account";
-
-// @relation(account.ref)
-/// A repository's account profile, stored at [`ACCOUNT_REF`].
-#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
-pub struct Account {
- /// The account's username — by convention the `user/<username>` repo name,
- /// but authoritative here rather than in the path.
- pub username: String,
- /// The human-facing display name; defaults to the username.
- pub display_name: String,
- /// A short free-text bio; `""` when unset.
- pub bio: String,
- /// When the account was created, as seconds since the Unix epoch.
- pub created_at: u64,
-}
-
-impl component::Document for Account {
- const REF: &'static str = ACCOUNT_REF;
-}
-
-impl component::Component for Account {
- const NOUN: &'static str = "account";
- const PLURAL: &'static str = "account";
-}
-
-/// Load the account profile at [`ACCOUNT_REF`] in `repo`, or `None` when the
-/// ref is absent — i.e. when the repository is not an account repo.
-pub fn load(repo: &Path) -> Result<Option<Account>, git_store::Error> {
- component::load(&git_store::Store::open(repo)?)
-}
-
-/// Write `account` to [`ACCOUNT_REF`] in `repo`, replacing any existing value
-/// as a new commit.
-pub fn store(repo: &Path, account: &Account) -> Result<(), git_store::Error> {
- component::store(&git_store::Store::open(repo)?, account, "Update account")
-}
-
-/// Whether `repo` is an account repo — whether it carries [`ACCOUNT_REF`].
-pub fn is_account_repo(repo: &Path) -> Result<bool, git_store::Error> {
- Ok(load(repo)?.is_some())
-}
-
-/// The account's stable, path-independent identity: the content hash of the
-/// very first [`Account`] ever recorded on [`ACCOUNT_REF`] — fixed at
-/// creation, so later profile edits (`display_name`, `bio`) never change it
-/// and a member's `@`-mention of it survives the account repo moving. `None`
-/// when the repo is not (yet) an account repo. Mirrors the genesis-key idiom
-/// `issues::new_id` uses: an identifier derived from content, never a stored
-/// field.
-///
-/// ## Requirements
-///
-/// @relation(account.genesis)
-pub fn genesis(repo: &Path) -> Result<Option<String>, git_store::Error> {
- git_store::Store::open(repo)?
- .history::<Account>(ACCOUNT_REF)?
- .last()
- .map(|(_at, account)| git_store::content_hash(account))
- .transpose()
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_account_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("account")
- }
-
- fn account() -> Account {
- Account {
- username: "alice".to_owned(),
- display_name: "Alice".to_owned(),
- bio: "builder of trees".to_owned(),
- created_at: 1_700_000_000,
- }
- }
-
- // @relation(account.ref, role=Verifies)
- #[test]
- fn store_then_load_round_trips_the_account() {
- let repo = unique_repo();
- store(&repo, &account()).unwrap();
- assert_eq!(load(&repo).unwrap(), Some(account()));
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn absent_account_ref_is_not_an_account_repo() {
- let repo = unique_repo();
- assert_eq!(load(&repo).unwrap(), None);
- assert!(!is_account_repo(&repo).unwrap());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn the_account_ref_marks_an_account_repo() {
- let repo = unique_repo();
- store(&repo, &account()).unwrap();
- assert!(is_account_repo(&repo).unwrap());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(storage.meta-ref, role=Verifies)
- #[test]
- fn loads_the_on_disk_account_format() {
- // A fixture written as the real on-disk layout — `username`,
- // `display_name`, `bio`, and `created_at` blobs — must keep loading,
- // guarding the Account document's shape against an incompatible change to
- // data already on a ref.
- let repo = unique_repo();
- write_account_doc(&repo, "alice", "Alice", "builder of trees", 1_700_000_000);
- assert_eq!(load(&repo).unwrap(), Some(account()));
- let _ = std::fs::remove_dir_all(&repo);
- }
-}
crates/git-ents-core/src/config.rs
@@ -1,163 +1,0 @@
-//! The repository's metadata, sourced from the `refs/meta/config` ref.
-//!
-//! A repository's loose metadata — its description, homepage, and topics — is
-//! first-class, members-gated, versioned data rather than worktree content or
-//! a loose git file. It lives on exactly one ref, `refs/meta/config`, whose
-//! tree is a [`Config`] document read and written through [`git_store`]. Keeping
-//! it on a meta ref (not in the worktree) means anyone who can push content
-//! cannot rewrite the repository's metadata, and the metadata carries its own
-//! independent history.
-
-use std::collections::BTreeMap;
-use std::path::Path;
-
-use facet::Facet;
-
-use git_store::component;
-
-// @relation(config.ref)
-/// The ref whose tree holds the repository configuration.
-pub const CONFIG_REF: &str = "refs/meta/config";
-
-// @relation(config.ref)
-/// The repository configuration stored at [`CONFIG_REF`].
-#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
-pub struct Config {
- /// The repository's description (was git's `.git/description` file).
- pub description: String,
- /// The repository's homepage URL; `""` when unset.
- pub homepage: String,
- /// The repository's topics, members-gated metadata rather than worktree
- /// content.
- pub topics: Vec<String>,
- /// Ref-push rules keyed by role name, matched against a pushing member's
- /// `role` (see `git_member::ref_allowed`). A role absent here — or a
- /// member with no role at all — permits every ref: role rules are opt-in
- /// gating layered on top of that default-allow-all rule.
- pub roles: BTreeMap<String, RoleRules>,
-}
-
-impl component::Document for Config {
- const REF: &'static str = CONFIG_REF;
-}
-
-impl component::Component for Config {
- const NOUN: &'static str = "configuration";
- const PLURAL: &'static str = "configuration";
-}
-
-/// The ref-push rules for one role: glob patterns (`*` matches any run of
-/// characters) matched against the full ref name (e.g. `refs/heads/*`).
-#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
-pub struct RoleRules {
- /// Refs this role may push to. Empty means "every ref not denied" —
- /// otherwise a ref must match at least one pattern here.
- pub allow: Vec<String>,
- /// Refs this role may never push to, checked before `allow`.
- pub deny: Vec<String>,
-}
-
-/// Load the configuration recorded at [`CONFIG_REF`] from an already-open
-/// `store`.
-///
-/// An absent ref yields [`Config::default`], as on a repository whose metadata
-/// has not been set yet. A present but unreadable ref is an error so callers can
-/// distinguish corruption from "no configuration set".
-///
-/// ## Requirements
-///
-/// @relation(config.ref)
-pub fn load_with(store: &git_store::Store) -> Result<Config, git_store::Error> {
- Ok(component::load::<Config>(store)?.unwrap_or_default())
-}
-
-/// Load the configuration recorded at [`CONFIG_REF`] in `repo`. See
-/// [`load_with`].
-pub fn load(repo: &Path) -> Result<Config, git_store::Error> {
- load_with(&git_store::Store::open(repo)?)
-}
-
-/// Write `config` to [`CONFIG_REF`] in `repo`, replacing any existing value as
-/// a new commit.
-///
-/// The web write path does not call this directly: it lands an edit through
-/// `git_ents_server::web::write::signed_edit`, which stages the commit on a
-/// throwaway ref and pushes it onto [`CONFIG_REF`] through a signed push, so
-/// the `pre-receive` gate judges the change rather than a direct write.
-pub fn store(repo: &Path, config: &Config) -> Result<(), git_store::Error> {
- component::store(
- &git_store::Store::open(repo)?,
- config,
- "Update configuration",
- )
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_config_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("config")
- }
-
- fn config() -> Config {
- Config {
- description: "A repository".to_owned(),
- homepage: "https://example.com".to_owned(),
- topics: vec!["rust".to_owned(), "git".to_owned()],
- roles: BTreeMap::new(),
- }
- }
-
- // @relation(config.ref, role=Verifies)
- #[test]
- fn store_then_load_round_trips_the_config() {
- let repo = unique_repo();
- store(&repo, &config()).unwrap();
- assert_eq!(load(&repo).unwrap(), config());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn store_replaces_the_previous_config() {
- let repo = unique_repo();
- store(&repo, &config()).unwrap();
- store(&repo, &Config::default()).unwrap();
- assert_eq!(load(&repo).unwrap(), Config::default());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(storage.meta-ref, role=Verifies)
- #[test]
- fn loads_the_on_disk_config_format() {
- // A fixture written as the real on-disk layout — `description` and
- // `homepage` blobs plus an index-keyed `topics/` subtree — must keep
- // loading, guarding the Config document's shape against an incompatible
- // change to data already on a ref.
- let repo = unique_repo();
- write_config_doc(
- &repo,
- CONFIG_REF,
- "A repository",
- "https://example.com",
- &["rust", "git"],
- );
- assert_eq!(load(&repo).unwrap(), config());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(config.ref, role=Verifies)
- #[test]
- fn default_when_the_config_ref_is_absent() {
- let repo = unique_repo();
- assert_eq!(load(&repo).unwrap(), Config::default());
- let _ = std::fs::remove_dir_all(&repo);
- }
-}
crates/git-ents-core/src/issues.rs
@@ -1,338 +1,0 @@
-//! The repository's issues, sourced from the `refs/meta/issues/<id>` refs.
-//!
-//! Each issue is a self-contained typed document on its own ref,
-//! `refs/meta/issues/<id>`, read and written through [`git_store`]. One ref per
-//! issue keeps issues independently loadable and historied — the ref's commit
-//! chain is the issue's edit history — and labels are plain strings so the index
-//! can derive its filter set from whatever labels exist, with no separate label
-//! registry to keep in sync.
-//!
-//! # Identity
-//!
-//! An issue carries two identifiers with two different jobs:
-//!
-//! * The **genesis key** — the ref's last segment, computed by [`new_id`] and
-//! never renamed — is the object id of the object the issue derives from (a
-//! review or proposal), or, when it derives from nothing, the hash of the
-//! issue's own initial content. Every issue is a git object, so there is no
-//! "no origin" case. Content-addressed and conflict-free: filing an issue
-//! never contends a counter, and one origin can never file the same issue
-//! twice. Cross-references (comments, reviews) key off this identifier, so
-//! it must never change.
-//! * The **friendly number** — the `id` field, `None` until [`promote_with`]
-//! assigns one — is lifecycle state, not a key. `Option` is the one field
-//! kind `facet-git-tree` auto-defaults on an absent entry, so adding this
-//! field is backward compatible with every issue ref already on disk:
-//! nothing but promotion ever touches the shared counter that assigns it.
-
-use std::path::Path;
-
-use facet::Facet;
-
-use git_store::component;
-
-// @relation(issues.ref)
-/// The namespace under which issues are recorded: one ref,
-/// `refs/meta/issues/<id>`, per issue.
-pub const ISSUES_NS: &str = "refs/meta/issues";
-
-/// The ref holding the shared friendly-number counter. Only [`promote_with`]
-/// advances it, so filing an issue never contends it.
-pub const ISSUE_NUMBER_REF: &str = "refs/meta/issue-number";
-
-/// An issue's state — the closed set `Issue.state` legitimately takes, in
-/// place of a `String` every caller had to trust held one of two values.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
-#[repr(u8)]
-pub enum State {
- /// The issue is being tracked.
- Open,
- /// The issue has been resolved or dismissed.
- Closed,
-}
-
-// @relation(issues.ref)
-/// One issue stored at `refs/meta/issues/<id>`.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Issue {
- /// The issue's one-line title.
- pub title: String,
- /// The issue's body text.
- pub body: String,
- /// The issue's state.
- pub state: State,
- /// The labels applied to the issue, as plain strings.
- pub labels: Vec<String>,
- /// The identity that opened the issue.
- pub author: String,
- /// The friendly sequential number [`promote_with`] assigned, or `None`
- /// before a maintainer promotes the issue. Lifecycle state, not the
- /// issue's key — the ref's genesis hash is that.
- pub id: Option<String>,
-}
-
-impl component::Collection for Issue {
- const NS: &'static str = ISSUES_NS;
-}
-
-impl component::Component for Issue {
- const NOUN: &'static str = "issue";
- const PLURAL: &'static str = "issues";
-}
-
-impl Issue {
- /// Whether the issue is open (any state other than [`State::Closed`]).
- #[must_use]
- pub fn is_open(&self) -> bool {
- self.state != State::Closed
- }
-}
-
-/// The `refs/meta/issue-number` document: the next friendly number a
-/// promotion will assign.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
-struct IssueNumber {
- next: u64,
-}
-
-/// Derive an issue's stable genesis key: `origin`'s object id (hex) when the
-/// issue derives from one — one origin, one issue, deduplicated on
-/// provenance — otherwise the hash of the issue's own initial content, since
-/// every issue is a git object and so always has one.
-///
-/// ## Requirements
-///
-/// @relation(issues.id)
-pub fn new_id(origin: Option<&str>, content: &Issue) -> Result<String, git_store::Error> {
- git_store::new_id(origin, content)
-}
-
-/// Load the issue recorded at `refs/meta/issues/<id>` in `repo`, or `None` when
-/// no such issue exists.
-pub fn load(repo: &Path, id: &str) -> Result<Option<Issue>, git_store::Error> {
- component::load_item(&git_store::Store::open(repo)?, id)
-}
-
-/// Write `issue` to `refs/meta/issues/<id>` in `repo`, replacing any existing
-/// value as a new commit so the ref's commit chain is the issue's edit history.
-pub fn store(repo: &Path, id: &str, issue: &Issue) -> Result<(), git_store::Error> {
- component::store_item(&git_store::Store::open(repo)?, id, issue, "Update issue")
-}
-
-/// List every issue in `repo` as `(id, issue)` pairs, newest issue ref first.
-pub fn list(repo: &Path) -> Result<Vec<(String, Issue)>, git_store::Error> {
- component::list(&git_store::Store::open(repo)?)
-}
-
-/// The number of open issues in `repo`.
-pub fn open_count(repo: &Path) -> Result<usize, git_store::Error> {
- Ok(list(repo)?
- .into_iter()
- .filter(|(_id, issue)| issue.is_open())
- .count())
-}
-
-/// Why [`promote`] could not promote an issue.
-#[derive(Debug, thiserror::Error)]
-pub enum PromoteError {
- /// The underlying store failed to read or write a ref.
- #[error(transparent)]
- Store(#[from] git_store::Error),
- /// No issue is recorded at `id`.
- #[error("no issue at {0:?}")]
- NotFound(String),
-}
-
-/// How many times [`promote`] retries the counter CAS before giving up.
-/// Bounds retry under sustained contention; ordinary races resolve in one or
-/// two rounds.
-const MAX_PROMOTE_RETRIES: usize = 5;
-
-/// Promote the issue at the stable genesis key `id`: allocate the next
-/// friendly number by CAS-incrementing [`ISSUE_NUMBER_REF`], then write it
-/// into the issue's `id` field as a new commit on the *same* ref — the ref is
-/// never renamed, so every cross-reference keyed off it still resolves.
-///
-/// The counter is advanced with [`Store::amend`](git_store::Store::amend),
-/// not [`Store::store`](git_store::Store::store): two promotions racing for
-/// the same number must never both succeed by merging, since a structural
-/// merge would consider two identical successor values equal and let both
-/// callers believe they claimed it. A CAS conflict here is retried by
-/// re-reading the counter, so the number handed back is always the one
-/// actually reserved for this call.
-///
-/// ## Requirements
-///
-/// @relation(issues.id)
-pub fn promote(repo: &Path, id: &str) -> Result<String, PromoteError> {
- let store = git_store::Store::open(repo)?;
- let mut number = None;
- for _ in 0..=MAX_PROMOTE_RETRIES {
- let current = store
- .load::<IssueNumber>(ISSUE_NUMBER_REF)?
- .unwrap_or(IssueNumber { next: 1 });
- let next = IssueNumber {
- next: current.next.saturating_add(1),
- };
- match store.amend(ISSUE_NUMBER_REF, &next, "Allocate issue number") {
- Ok(()) => {
- number = Some(current.next);
- break;
- }
- Err(git_store::Error::Conflict) => continue,
- Err(error) => return Err(error.into()),
- }
- }
- let number = number.ok_or(git_store::Error::Conflict)?.to_string();
-
- let mut issue = component::load_item::<Issue>(&store, id)?
- .ok_or_else(|| PromoteError::NotFound(id.to_owned()))?;
- issue.id = Some(number.clone());
- component::store_item(&store, id, &issue, "Update issue")?;
- Ok(number)
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "unit test"
- )]
-
- use super::*;
- use crate::testutil::{unique_repo as new_repo, write_issue_doc};
-
- fn unique_repo() -> std::path::PathBuf {
- new_repo("issues")
- }
-
- fn issue(title: &str, state: State, labels: &[&str]) -> Issue {
- Issue {
- title: title.to_owned(),
- body: "A body".to_owned(),
- state,
- labels: labels.iter().map(|l| (*l).to_owned()).collect(),
- author: "alice".to_owned(),
- id: None,
- }
- }
-
- // @relation(issues.ref, role=Verifies)
- #[test]
- fn store_then_load_round_trips_an_issue() {
- let repo = unique_repo();
- let written = issue("A bug", State::Open, &["bug", "p1"]);
- store(&repo, "1", &written).unwrap();
- assert_eq!(load(&repo, "1").unwrap(), Some(written));
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn none_when_the_issue_is_absent() {
- let repo = unique_repo();
- assert_eq!(load(&repo, "1").unwrap(), None);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn lists_issues_and_counts_the_open_ones() {
- let repo = unique_repo();
- store(&repo, "1", &issue("Open one", State::Open, &["bug"])).unwrap();
- store(&repo, "2", &issue("Closed one", State::Closed, &[])).unwrap();
- let mut ids: Vec<String> = list(&repo).unwrap().into_iter().map(|(id, _)| id).collect();
- ids.sort();
- assert_eq!(ids, vec!["1".to_owned(), "2".to_owned()]);
- assert_eq!(open_count(&repo).unwrap(), 1);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(storage.meta-ref, role=Verifies)
- #[test]
- fn loads_the_on_disk_issue_format() {
- // A fixture written as the real on-disk layout — `title`, `body`,
- // `author` blobs, a `state/<Variant>` subtree, and an index-keyed
- // `labels/` subtree — must keep loading, guarding the Issue
- // document's shape against an incompatible change to data already on
- // a ref.
- let repo = unique_repo();
- write_issue_doc(
- &repo,
- &format!("{ISSUES_NS}/1"),
- "A bug",
- "A body",
- "Open",
- &["bug", "p1"],
- "alice",
- );
- assert_eq!(
- load(&repo, "1").unwrap(),
- Some(issue("A bug", State::Open, &["bug", "p1"]))
- );
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(issues.id, role=Verifies)
- #[test]
- fn new_id_uses_the_origin_when_one_is_given() {
- let content = issue("A bug", State::Open, &[]);
- assert_eq!(new_id(Some("deadbeef"), &content).unwrap(), "deadbeef");
- }
-
- // @relation(issues.id, role=Verifies)
- #[test]
- fn new_id_hashes_its_own_content_with_no_origin() {
- let a = issue("A bug", State::Open, &[]);
- let b = issue("A different bug", State::Open, &[]);
- let a_id = new_id(None, &a).unwrap();
- let b_id = new_id(None, &b).unwrap();
- // Content-addressed: same content yields the same id, different
- // content yields a different one, with no counter involved.
- assert_eq!(a_id, new_id(None, &a).unwrap());
- assert_ne!(a_id, b_id);
- }
-
- // @relation(issues.id, role=Verifies)
- #[test]
- fn filing_an_issue_leaves_its_friendly_number_unset() {
- let repo = unique_repo();
- let content = issue("A bug", State::Open, &[]);
- let id = new_id(None, &content).unwrap();
- store(&repo, &id, &content).unwrap();
- assert_eq!(load(&repo, &id).unwrap().unwrap().id, None);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- // @relation(issues.id, role=Verifies)
- #[test]
- fn promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref() {
- let repo = unique_repo();
- let content = issue("A bug", State::Open, &[]);
- let id = new_id(None, &content).unwrap();
- store(&repo, &id, &content).unwrap();
-
- let first = promote(&repo, &id).unwrap();
- assert_eq!(first, "1");
- let promoted = load(&repo, &id).unwrap().unwrap();
- assert_eq!(promoted.id, Some("1".to_owned()));
-
- // A second issue promotes to the next number; the first issue's ref
- // — keyed by its stable genesis hash — still resolves.
- let other = issue("Another bug", State::Open, &[]);
- let other_id = new_id(None, &other).unwrap();
- store(&repo, &other_id, &other).unwrap();
- assert_eq!(promote(&repo, &other_id).unwrap(), "2");
- assert_eq!(load(&repo, &id).unwrap().unwrap().id, Some("1".to_owned()));
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn promoting_an_absent_issue_fails() {
- let repo = unique_repo();
- assert!(matches!(
- promote(&repo, "missing"),
- Err(PromoteError::NotFound(id)) if id == "missing"
- ));
- let _ = std::fs::remove_dir_all(&repo);
- }
-}
crates/git-ents-core/src/lib.rs
@@ -1,13 +1,0 @@
-//! Git Ents core — the shared domain types read and written through
-//! `git_store`, common to the CLI porcelain and the server.
-
-pub mod account;
-pub mod config;
-pub mod issues;
-#[cfg(test)]
-mod testutil;
-
-/// The all-zero object id git uses for a created or deleted ref in a push
-/// (`<old> <new> <ref>` lines): a zero `<old>` is a create, a zero `<new>` a
-/// delete.
-pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
crates/git-ents-core/src/testutil.rs
@@ -1,176 +1,0 @@
-//! Shared test helpers for the meta-ref modules: a throwaway git repository and
-//! a builder that lays an on-disk `refs/meta/*` document out with raw git
-//! plumbing.
-//!
-//! Building the tree directly — rather than through [`git_store::Store`] — pins
-//! the *on-disk* layout each document type promises: a `<subtree>/<key>` blob
-//! per entry. A load test against a fixture written this way fails the moment an
-//! incompatible change to a document's [`facet::Facet`] shape stops reading data
-//! already in the wild, the failure mode that broke every push once before.
-
-#![allow(
- clippy::unwrap_used,
- clippy::let_underscore_must_use,
- reason = "test support"
-)]
-
-use std::path::{Path, PathBuf};
-use std::process::Command;
-use std::sync::atomic::{AtomicUsize, Ordering};
-
-/// A freshly initialized, uniquely named git repository under the temp dir.
-#[must_use]
-pub(crate) fn unique_repo(label: &str) -> PathBuf {
- static COUNTER: AtomicUsize = AtomicUsize::new(0);
- let n = COUNTER.fetch_add(1, Ordering::SeqCst);
- let dir = std::env::temp_dir().join(format!("git-ents-{label}-{}-{n}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- let status = Command::new("git")
- .arg("-C")
- .arg(&dir)
- .args(["init", "-q"])
- .status()
- .unwrap();
- assert!(status.success());
- for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test")] {
- let status = Command::new("git")
- .arg("-C")
- .arg(&dir)
- .args(["config", key, value])
- .status()
- .unwrap();
- assert!(status.success());
- }
- dir
-}
-
-/// Lay an `Account` document out at `refs/meta/account` as the real on-disk
-/// format: `username`, `display_name`, `bio`, and `created_at` blobs (the
-/// integer in its decimal `Display` form). Asserts the loader still reads the
-/// format independent of the writer.
-pub(crate) fn write_account_doc(
- repo: &Path,
- username: &str,
- display_name: &str,
- bio: &str,
- created_at: u64,
-) {
- let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value);
- let username_blob = blob(username);
- let display_blob = blob(display_name);
- let bio_blob = blob(bio);
- let created_blob = blob(&created_at.to_string());
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {created_blob}\tcreated_at\n\
- 100644 blob {display_blob}\tdisplay_name\n\
- 100644 blob {bio_blob}\tbio\n\
- 100644 blob {username_blob}\tusername\n"
- ),
- );
- let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", "refs/meta/account", &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Lay a `Config` document out at `refname` as the real on-disk format: a
-/// `description` blob, a `homepage` blob, and a `topics/` subtree of index-keyed
-/// (`0000`, `0001`, …) blobs, committed and pointed to by the ref. Asserts the
-/// loader still reads the format independent of the writer.
-pub(crate) fn write_config_doc(
- repo: &Path,
- refname: &str,
- description: &str,
- homepage: &str,
- topics: &[&str],
-) {
- let description_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], description);
- let homepage_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], homepage);
- let mut topic_entries = String::new();
- for (index, topic) in topics.iter().enumerate() {
- let blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], topic);
- topic_entries.push_str(&format!("100644 blob {blob}\t{index:04}\n"));
- }
- let topics_tree = git_with_stdin(repo, &["mktree"], &topic_entries);
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {description_blob}\tdescription\n\
- 100644 blob {homepage_blob}\thomepage\n\
- 040000 tree {topics_tree}\ttopics\n"
- ),
- );
- let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", refname, &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Lay an `Issue` document out at `refname` as the real on-disk format:
-/// `title`, `body`, and `author` blobs, a `state/<Variant>` subtree (the
-/// `State` enum's unit variant resolving to an empty tree, exactly like
-/// `Member`'s `provenance`), and an index-keyed (`0000`, `0001`, …) `labels/`
-/// subtree, committed and pointed to by the ref. `state` is the `State`
-/// variant's name (`"Open"`, `"Closed"`). Asserts the loader still reads the
-/// format independent of the writer.
-pub(crate) fn write_issue_doc(
- repo: &Path,
- refname: &str,
- title: &str,
- body: &str,
- state: &str,
- labels: &[&str],
- author: &str,
-) {
- let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value);
- let title_blob = blob(title);
- let body_blob = blob(body);
- let author_blob = blob(author);
- let empty_tree = git_with_stdin(repo, &["mktree"], "");
- let state_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!("040000 tree {empty_tree}\t{state}\n"),
- );
- let mut label_entries = String::new();
- for (index, label) in labels.iter().enumerate() {
- label_entries.push_str(&format!("100644 blob {}\t{index:04}\n", blob(label)));
- }
- let labels_tree = git_with_stdin(repo, &["mktree"], &label_entries);
- let root = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {title_blob}\ttitle\n\
- 100644 blob {body_blob}\tbody\n\
- 040000 tree {state_tree}\tstate\n\
- 040000 tree {labels_tree}\tlabels\n\
- 100644 blob {author_blob}\tauthor\n"
- ),
- );
- let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", refname, &commit])
- .status()
- .unwrap();
- assert!(status.success());
-}
-
-/// Run git in `repo` with `input` on stdin, returning its trimmed stdout.
-fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String {
- git_store::test_support::git_with_stdin(repo, args, input)
-}
crates/git-ents-server/Cargo.toml
@@ -1,53 +1,0 @@
-[package]
-name = "git-ents-server"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-git-ents-core = { workspace = true }
-acdc-parser = { workspace = true }
-askama = { workspace = true }
-acdc-converters-core = { workspace = true }
-acdc-converters-html = { workspace = true }
-acdc-converters-terminal = { workspace = true }
-arborium = { workspace = true }
-axum = { workspace = true, features = ["ws"] }
-facet = { workspace = true }
-figue = { workspace = true }
-form_urlencoded = { workspace = true }
-getrandom = { workspace = true }
-git-anchor = { workspace = true }
-git-backend = { workspace = true }
-git-comment = { workspace = true }
-git-effect = { workspace = true }
-git-hydrate = { workspace = true }
-git-maintenance = { workspace = true }
-git-member = { workspace = true }
-git-protocol = { workspace = true }
-gix-reachability = { workspace = true }
-git-signed-push = { workspace = true }
-git-store = { workspace = true }
-git-toolchain = { workspace = true }
-gix-actor = { workspace = true }
-gix-date = { workspace = true }
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-maud = { workspace = true }
-odb-files = { workspace = true }
-odb-tigris = { workspace = true }
-pulldown-cmark = { workspace = true }
-refstore-files = { workspace = true }
-refstore-postgres = { workspace = true }
-tempfile = { workspace = true }
-tokio = { workspace = true }
-uuid = { workspace = true }
-portable-pty = "0.9.0"
-
-[dev-dependencies]
-backend-conformance = { workspace = true }
-rstest = { workspace = true }
-
-[lints]
-workspace = true
crates/git-ents-server/Dockerfile
@@ -1,39 +1,0 @@
-FROM lukemathwalker/cargo-chef:latest-rust-1-bookworm AS chef
-WORKDIR /app
-
-FROM chef AS planner
-COPY . .
-RUN cargo chef prepare --recipe-path recipe.json
-
-FROM chef AS builder
-# acdc's terminal-replay rendering pulls in libghostty-vt-sys, whose build
-# script shells out to `zig build` and pins an exact toolchain version.
-RUN curl -fsSL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz \
- | tar -xJ -C /usr/local \
- && ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig
-COPY --from=planner /app/recipe.json recipe.json
-# Build dependencies - this is the caching Docker layer!
-RUN cargo chef cook --release --recipe-path recipe.json
-# Build application
-COPY . .
-RUN cargo build --release --bin git-ents-server
-
-# We do not need the Rust toolchain to run the binary!
-FROM debian:bookworm-slim AS runtime
-WORKDIR /app
-# openssh-client provides the `ssh-keygen -Y verify` the pre-receive hook runs;
-# curl installs the sprite CLI the post-receive check runner drives.
-RUN apt-get update \
- && apt-get install -y --no-install-recommends git ca-certificates openssh-client curl \
- && rm -rf /var/lib/apt/lists/*
-# The sprite CLI runs the checks in a Sprite; it reads SPRITES_TOKEN from the env.
-# The installer drops the binary in $HOME/.local/bin and never touches PATH, so
-# point it at /usr/local/bin (already on PATH) where the server can spawn it.
-RUN curl -fsSL https://sprites.dev/install.sh \
- | env SPRITE_INSTALL_PREFERRED_DIRS=/usr/local/bin \
- SPRITE_INSTALL_DEFAULT_BIN_DIR=/usr/local/bin bash
-COPY --from=builder /app/target/release/git-ents-server /usr/local/bin
-COPY hooks /app/hooks
-RUN chmod +x /app/hooks/pre-receive /app/hooks/post-receive
-ENV GIT_ENTS_HOOKS_DIR=/app/hooks
-ENTRYPOINT ["/usr/local/bin/git-ents-server"]
crates/git-ents-server/src/asciidoc.rs
@@ -1,229 +1,0 @@
-//! AsciiDoc rendering via the [`acdc`](https://github.com/nlopes/acdc) library.
-//!
-//! The forge treats AsciiDoc as its prose format: a repository's `README.adoc`
-//! becomes the editorial centerpiece of the overview, and `.adoc`/`.asciidoc`
-//! blobs render as formatted documents rather than highlighted source. Output is
-//! the *embedded* fragment (no `<!DOCTYPE>`/`<html>` frame) so it can drop
-//! straight into a card body styled by the page's own stylesheet.
-
-use acdc_converters_core::{
- Converter, Diagnostics, Options as ConvertOptions, WarningSource, inlines_to_string,
-};
-use acdc_converters_html::{Processor, RenderOptions};
-use acdc_parser::Options as ParseOptions;
-use maud::html;
-
-/// File extensions that name an AsciiDoc document.
-const EXTENSIONS: [&str; 4] = ["adoc", "asciidoc", "asc", "adc"];
-
-/// Whether `name` looks like an AsciiDoc file by its extension.
-pub(crate) fn is_asciidoc(name: &str) -> bool {
- name.rsplit_once('.')
- .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
-}
-
-/// Render AsciiDoc `source` to an embedded HTML fragment, or `None` if it cannot
-/// be parsed or converted. The fragment carries no document frame, so callers
-/// place it inside their own container.
-///
-/// ## Requirements
-///
-/// @relation(web.render-registry, web.syntax-highlight)
-pub(crate) fn to_html(source: &str) -> Option<String> {
- let parsed = acdc_parser::parse(source, &ParseOptions::default()).ok()?;
- let doc = parsed.document();
-
- // Embedded mode omits the document frame *and* the visible doctitle, so
- // rebuild the title and subtitle from the parsed header — the README's h1 is
- // the centerpiece of the overview.
- let heading = doc
- .header
- .as_ref()
- .filter(|h| !h.title.is_empty())
- .map(|h| {
- let title = inlines_to_string(&h.title);
- let subtitle = h.subtitle.as_ref().map(|s| inlines_to_string(s));
- html! {
- h1 { (title) }
- @if let Some(subtitle) = subtitle {
- p.doc-subtitle { (subtitle) }
- }
- }
- .into_string()
- });
-
- let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone());
- let options = RenderOptions {
- embedded: true,
- ..RenderOptions::default()
- };
- let body = processor.convert_to_string(doc, &options).ok()?;
- Some(match heading {
- Some(heading) => heading + &body,
- None => body,
- })
-}
-
-/// Render AsciiDoc `source` to plain text via acdc's `cat`-like terminal
-/// converter — the same parser [`to_html`] uses, feeding a converter meant
-/// for TTY output (a shell, `git ents comment show`) instead of a browser.
-pub(crate) fn to_text(source: &str) -> Option<String> {
- let parsed = acdc_parser::parse(source, &ParseOptions::default()).ok()?;
- let doc = parsed.document();
- let processor =
- acdc_converters_terminal::Processor::new(ConvertOptions::default(), doc.attributes.clone());
- let mut output = Vec::new();
- let source = WarningSource::new("terminal");
- let mut warnings = Vec::new();
- let mut diagnostics = Diagnostics::new(&source, &mut warnings);
- processor
- .write_to(doc, &mut output, None, None, &mut diagnostics)
- .ok()?;
- for warning in &warnings {
- eprintln!("asciidoc text render: {warning}");
- }
- String::from_utf8(output).ok()
-}
-
-/// CSS for the `.terminal-view` player acdc's HTML converter emits, vendored
-/// here because embedded-fragment output carries no `<head>` to link or inline
-/// it from (see [`to_html`]'s doctitle note for the same embedded-mode gap).
-/// Based on acdc's built-in stylesheet (keep the box/layout rules in sync if it
-/// drifts), but `--light`/`--dark` are repainted with the site's own theme
-/// variables (`crates/git-ents-server/src/web/style.css`) rather than acdc's
-/// fixed hex pair: our synthesized `[terminal]`/`[terminal%replay]` source never
-/// carries a `:dark-mode:` attribute, so acdc always picks `--light`, which
-/// otherwise renders a fixed light-on-light box no matter the browser's theme.
-pub(crate) const TERMINAL_VIEW_CSS: &str = "\
-.terminal-view{margin:1.25em 0;max-width:100%;overflow:auto;border-radius:var(--radius-sm);border:1px solid var(--color-border);box-shadow:var(--shadow-sm)}
-.terminal-view__screen{margin:0;padding:18px;font:14px/1.45 var(--font-mono);white-space:pre;tab-size:4}
-.terminal-view--light,.terminal-view--dark{background-color:var(--color-code-bg);color:var(--color-text)}
-.terminal-view__viewport{overflow:auto;max-width:100%;padding:0 18px 18px}
-.terminal-view__stream{margin:0;padding:0;width:max-content;font-family:var(--font-mono);font-size:14px;line-height:1.2;white-space:normal;tab-size:4}
-.terminal-view__row{white-space:pre;min-height:1.2em}
-";
-
-/// Whether an asciicast v2/v3 `recording` decodes to no visible terminal
-/// output — e.g. a check that passed without printing anything beyond a
-/// trailing newline. acdc's replay player renders this as a bare empty box
-/// with no explanation, so callers should check this first and show their
-/// own message instead. Checking the decoded bytes rather than the raw JSONL
-/// lines matters: an output event carrying just `"\n"` is a non-empty JSON
-/// line but has nothing worth showing.
-pub(crate) fn recording_has_no_output(recording: &str) -> bool {
- extract_output(recording).trim().is_empty()
-}
-
-/// Render the *current* screen of an in-progress asciicast v2 recording as a
-/// static terminal snapshot via acdc's plain `[terminal]` block (no replay
-/// scrubber — a running check has no fixed timeline yet, just a screen that
-/// keeps changing), or `None` if it cannot be parsed or converted. The
-/// asciicast recording stays the single source of truth for the check's
-/// output; this only reconstitutes the raw bytes acdc's terminal emulator
-/// needs; unlike [`render_recording`], it does not go through acdc's asciicast
-/// parser, since that produces a scrubbable timeline rather than one snapshot.
-pub(crate) fn render_live(recording: &str) -> Option<String> {
- let ansi = extract_output(recording);
- let source = format!("[terminal]\n----\n{ansi}\n----\n");
- let parsed = acdc_parser::parse(&source, &ParseOptions::default()).ok()?;
- let doc = parsed.document();
- let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone());
- let options = RenderOptions {
- embedded: true,
- ..RenderOptions::default()
- };
- let mut output = Vec::new();
- let source = WarningSource::new("html").with_variant("live-recording");
- let mut warnings = Vec::new();
- let mut diagnostics = Diagnostics::new(&source, &mut warnings);
- processor
- .convert_to_writer(doc, &mut output, &options, &mut diagnostics)
- .ok()?;
- for warning in &warnings {
- eprintln!("live check recording render: {warning}");
- }
- String::from_utf8(output).ok()
-}
-
-/// Concatenate every `[time, "o", data]` event's `data` field out of an
-/// asciicast v2 recording, in order, undoing the JSON escaping the checks
-/// worker applies when it writes them — the raw terminal bytes underneath the
-/// recording, for feeding to a *static* terminal renderer (see
-/// [`render_live`]). The finished-recording path doesn't need this: acdc's own
-/// asciicast parser (used by [`render_recording`]) reads the format natively.
-fn extract_output(recording: &str) -> String {
- let mut out = String::new();
- for line in recording.lines().skip(1) {
- if let Some(data) = event_data(line) {
- out.push_str(&data);
- }
- }
- out
-}
-
-/// Extract and unescape the `data` field of one `[time, "o", "data"]` event
-/// line, or `None` if the line does not look like one.
-fn event_data(line: &str) -> Option<String> {
- const MARKER: &str = "\"o\", \"";
- let start = line.find(MARKER)?.checked_add(MARKER.len())?;
- let rest = line.get(start..)?;
- let end = rest.rfind("\"]")?;
- Some(unescape_json_string(rest.get(..end)?))
-}
-
-/// The inverse of the checks worker's hand-rolled JSON string escaping:
-/// unescape `"`, `\`, the recognized single-character escapes, and `\uXXXX`
-/// control-code escapes, passing everything else through unchanged.
-fn unescape_json_string(escaped: &str) -> String {
- let mut out = String::with_capacity(escaped.len());
- let mut chars = escaped.chars();
- while let Some(ch) = chars.next() {
- if ch != '\\' {
- out.push(ch);
- continue;
- }
- match chars.next() {
- Some('"') => out.push('"'),
- Some('\\') => out.push('\\'),
- Some('n') => out.push('\n'),
- Some('r') => out.push('\r'),
- Some('t') => out.push('\t'),
- Some('u') => {
- let hex: String = chars.by_ref().take(4).collect();
- if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
- out.push(ch);
- }
- }
- Some(other) => out.push(other),
- None => {}
- }
- }
- out
-}
-
-/// Render an asciicast v2/v3 `recording` as a replayable terminal session via
-/// acdc's `[terminal%replay]` block, or `None` if it cannot be parsed or
-/// converted. Wraps `recording` in a listing block, so a recording containing
-/// a `----` line of its own would break out early; asciicast JSONL never
-/// produces that on its own line.
-pub(crate) fn render_recording(recording: &str) -> Option<String> {
- let source = format!("[terminal%replay,format=asciicast]\n----\n{recording}\n----\n");
- let parsed = acdc_parser::parse(&source, &ParseOptions::default()).ok()?;
- let doc = parsed.document();
- let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone());
- let options = RenderOptions {
- embedded: true,
- ..RenderOptions::default()
- };
- let mut output = Vec::new();
- let source = WarningSource::new("html").with_variant("recording");
- let mut warnings = Vec::new();
- let mut diagnostics = Diagnostics::new(&source, &mut warnings);
- processor
- .convert_to_writer(doc, &mut output, &options, &mut diagnostics)
- .ok()?;
- for warning in &warnings {
- eprintln!("check recording render: {warning}");
- }
- String::from_utf8(output).ok()
-}
crates/git-ents-server/src/http.rs
@@ -1,792 +1,0 @@
-//! Smart-HTTP gateway: delegates the git protocol to `git http-backend`.
-//!
-//! Every request is handed to git's `http-backend` CGI, which implements the
-//! full smart-HTTP protocol (running `git-upload-pack` for fetch and
-//! `git-receive-pack` for push). This module only translates between Axum
-//! requests/responses and the CGI's stdin/stdout.
-
-use std::path::{Path, PathBuf};
-use std::process::Stdio;
-
-use axum::body::{Body, Bytes};
-use axum::extract::State;
-use axum::http::{HeaderMap, Method, StatusCode, Uri};
-use axum::response::{IntoResponse, Response};
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::process::Command;
-
-use crate::AppState;
-
-const CGI_HEADER_SEP: &[u8] = b"\r\n\r\n";
-
-// @relation(deploy.health)
-/// A liveness probe (and the `/` root) that does not touch git.
-pub async fn health() -> &'static str {
- "ok"
-}
-
-// @relation(protocol.routing)
-/// Serve a GET: the HTML web UI for browser requests, or `git http-backend` for
-/// a git wire-protocol read (the ref advertisement or a dumb-HTTP object fetch).
-pub async fn get_request(State(state): State<AppState>, uri: Uri, headers: HeaderMap) -> Response {
- let path_info = uri.path().to_owned();
- let query_string = uri.query().unwrap_or_default().to_owned();
-
- if path_info.contains("..") {
- return (StatusCode::BAD_REQUEST, "bad request").into_response();
- }
-
- // Anything that is not part of the git wire protocol is served the HTML web
- // UI rather than handed to the CGI backend.
- if is_web_get(&path_info, &query_string) {
- let host = header_value(&headers, "Host");
- let cookie = header_value(&headers, "Cookie");
- let referer = header_value(&headers, "Referer");
- let query = (!query_string.is_empty()).then_some(query_string.as_str());
- return crate::web::render(
- &state,
- &path_info,
- query,
- host.as_deref(),
- cookie.as_deref(),
- referer.as_deref(),
- )
- .await;
- }
-
- backend(
- &state,
- Method::GET,
- &path_info,
- &query_string,
- &headers,
- Bytes::new(),
- )
- .await
-}
-
-// @relation(protocol.routing)
-/// Serve a POST: always a git smart-HTTP RPC (`git-upload-pack` for fetch or
-/// `git-receive-pack` for push). The browser UI never POSTs, so there is no web
-/// branch here.
-pub async fn post_request(
- State(state): State<AppState>,
- uri: Uri,
- headers: HeaderMap,
- body: Bytes,
-) -> Response {
- let path_info = uri.path().to_owned();
- let query_string = uri.query().unwrap_or_default().to_owned();
-
- if path_info.contains("..") {
- return (StatusCode::BAD_REQUEST, "bad request").into_response();
- }
-
- // The browser UI POSTs to sign in, sign out, and save edits; only the two
- // git smart-HTTP RPCs go to the backend.
- if !is_git_post(&path_info) {
- return crate::web::handle_post(&state, &path_info, &headers, body).await;
- }
-
- backend(
- &state,
- Method::POST,
- &path_info,
- &query_string,
- &headers,
- body,
- )
- .await
-}
-
-// @relation(protocol.routing)
-/// Whether a POST is a git smart-HTTP RPC rather than a browser form submission.
-fn is_git_post(path_info: &str) -> bool {
- path_info.ends_with("/git-upload-pack") || path_info.ends_with("/git-receive-pack")
-}
-
-// @relation(protocol.git, compat.git, compat.cgi, nonfunctional.concurrency)
-/// Hand a git wire-protocol request to `git http-backend` and reply with its
-/// output. A receive-pack request (push) auto-creates its bare repository before
-/// the backend runs and reconciles `HEAD` after a successful push.
-async fn backend(
- state: &AppState,
- method: Method,
- path_info: &str,
- query_string: &str,
- headers: &HeaderMap,
- body: Bytes,
-) -> Response {
- // A push uses exactly two endpoints: the receive-pack advertisement
- // (`GET /<repo>/info/refs?service=git-receive-pack`) and the receive-pack
- // RPC (`POST /<repo>/git-receive-pack`). Recognize the target so the bare
- // repo can be auto-created on the very first request; reject an
- // unacceptable repository path here rather than handing it to the backend.
- let push_repo = if is_receive_pack(path_info, query_string) {
- match repo_path(path_info) {
- Some(relative) => Some(state.data_dir.join(relative)),
- None => return (StatusCode::BAD_REQUEST, "invalid repository path").into_response(),
- }
- } else {
- None
- };
- if let Some(repo) = &push_repo
- && let Err(response) = ensure_repo(state, repo).await
- {
- return response;
- }
-
- // WS0 hydration (`docs/scale-out.adoc`): before handing this request to
- // `git http-backend`, top up the ephemeral local repo from the durable
- // stores and, for anything answering a ref advertisement, regenerate
- // `packed-refs` from Postgres — bounding advertisement staleness to
- // this one request. A no-op (and `state.hydrate` is `None`) for a
- // local-only deployment.
- if let Some(hydrate) = &state.hydrate
- && is_service_request(path_info, query_string)
- && let Some(relative) = repo_path(path_info)
- {
- let repo_path = state.data_dir.join(&relative);
- let repo_id = repo_id_string(&relative);
- if let Err(response) = hydrate_repo(hydrate, &repo_path, &repo_id).await {
- return response;
- }
- }
-
- let content_type = header_value(headers, "Content-Type");
- let content_length = header_value(headers, "Content-Length");
- // `Content-Type`/`Content-Length` are CGI-special-cased env vars with no
- // `HTTP_` prefix; every other header (including `Content-Encoding`) maps to
- // `HTTP_<NAME>`. Without it, a gzip'd request body — which git's client
- // sends once a negotiation grows past its size threshold, e.g. `fetch`ing
- // many refs at once — reaches `git-upload-pack` still compressed, which it
- // reads as raw (garbled) pkt-lines: "protocol error: bad line length
- // character".
- let content_encoding = header_value(headers, "Content-Encoding");
-
- let mut cmd = Command::new("git");
- cmd.arg("http-backend")
- .env("GIT_PROJECT_ROOT", &state.data_dir)
- .env("GIT_HTTP_EXPORT_ALL", "1")
- .env("PATH_INFO", path_info)
- .env("QUERY_STRING", query_string)
- .env("REQUEST_METHOD", method.as_str())
- // Hand the `post-receive` hook the queue it drops jobs into; it inherits
- // this through the receive-pack process tree git spawns.
- .env(git_effect::engine::QUEUE_ENV, &state.checks_queue)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- // Surface backend diagnostics in the server's own logs rather than
- // discarding them; git http-backend only writes here when something
- // goes wrong.
- .stderr(Stdio::inherit());
- if let Some(value) = &content_type {
- cmd.env("CONTENT_TYPE", value);
- }
- if let Some(value) = &content_length {
- cmd.env("CONTENT_LENGTH", value);
- }
- if let Some(value) = &content_encoding {
- cmd.env("HTTP_CONTENT_ENCODING", value);
- }
- // Hand the hydration-mode `pre-receive` (`git_hydrate::pre_receive`) the
- // durable-store config it needs: these env vars propagate through
- // `http-backend` -> `receive-pack` -> the hook exactly as
- // `git_effect::engine::QUEUE_ENV` above already does, and
- // `git_hydrate::HydrateConfig::from_env` reads them back.
- if let Some(hydrate) = &state.hydrate {
- // The op record's signer: `git_hydrate::pre_receive` needs the same
- // key `AppState::web_signing_key` already holds in this process,
- // but the hook is a separate process with no access to it.
- if let Some(key) = &state.web_signing_key {
- cmd.env("GIT_ENTS_WEB_SIGNING_KEY", key);
- }
- cmd.env("GIT_ENTS_HYDRATE_POSTGRES_URL", &hydrate.postgres_conninfo);
- match &hydrate.blob {
- git_hydrate::config::BlobStore::Fs(root) => {
- cmd.env("GIT_ENTS_HYDRATE_BLOB_ROOT", root);
- }
- git_hydrate::config::BlobStore::S3(s3) => {
- cmd.env("GIT_ENTS_HYDRATE_S3_BUCKET", &s3.bucket)
- .env("GIT_ENTS_HYDRATE_S3_REGION", &s3.region)
- .env("GIT_ENTS_HYDRATE_S3_ENDPOINT", &s3.endpoint)
- .env("GIT_ENTS_HYDRATE_S3_ACCESS_KEY_ID", &s3.access_key_id)
- .env(
- "GIT_ENTS_HYDRATE_S3_SECRET_ACCESS_KEY",
- &s3.secret_access_key,
- );
- if s3.allow_http {
- cmd.env("GIT_ENTS_HYDRATE_S3_ALLOW_HTTP", "1");
- }
- }
- }
- }
-
- // Push these through `GIT_CONFIG_*` rather than `git -c` so they reach the
- // `receive-pack` and `pre-receive` processes http-backend spawns, where the
- // nonce and hook actually take effect.
- let overrides = backend_config(state);
- if !overrides.is_empty() {
- cmd.env("GIT_CONFIG_COUNT", overrides.len().to_string());
- for (index, (key, value)) in overrides.iter().enumerate() {
- cmd.env(format!("GIT_CONFIG_KEY_{index}"), key);
- cmd.env(format!("GIT_CONFIG_VALUE_{index}"), value);
- }
- }
-
- let mut child = match cmd.spawn() {
- Ok(child) => child,
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("spawn failed: {e}"),
- )
- .into_response();
- }
- };
-
- // Feed the request body and drain stdout concurrently; receive-pack streams
- // progress to stdout while still reading the pack, so a sequential
- // write-then-read would deadlock on a full pipe.
- let writer = child.stdin.take().map(|mut stdin| {
- tokio::spawn(async move {
- let _write = stdin.write_all(&body).await;
- // `stdin` drops here, closing the pipe so the CGI sees EOF.
- })
- });
-
- let mut stdout = Vec::new();
- if let Some(mut out) = child.stdout.take() {
- let _read = out.read_to_end(&mut stdout).await;
- }
- if let Some(writer) = writer {
- let _joined = writer.await;
- }
-
- let status = match child.wait().await {
- Ok(status) => status,
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("backend failed: {e}"),
- )
- .into_response();
- }
- };
-
- // A fresh bare repo's `HEAD` points at its initial branch, which may not be
- // the branch the client just pushed; without a valid `HEAD`, clones check
- // out nothing. Adopt a pushed branch so the repo stays cloneable.
- if let Some(repo) = &push_repo
- && status.success()
- {
- reconcile_head(repo).await;
- }
-
- build_response(&stdout)
-}
-
-// @relation(compat.git, auth.nonce)
-/// The `git` config overrides applied to every backend invocation. Empty until
-/// push authentication is wired: a seed enables the signed-push nonce, and the
-/// hooks directory points the backend at the `pre-receive` verifier.
-fn backend_config(state: &AppState) -> Vec<(&'static str, &str)> {
- let mut overrides = Vec::new();
- if let Some(seed) = state.cert_nonce_seed.as_deref() {
- overrides.push(("receive.certNonceSeed", seed));
- // Smart-HTTP issues the nonce and verifies it in two separate
- // `receive-pack` processes, so the cert's stamp never matches the
- // verifier's "now"; without a slop window git's default of 0 returns
- // SLOP for every signed push. Allow a small drift for the round-trip.
- overrides.push(("receive.certNonceSlop", "60"));
- }
- if let Some(hooks) = state.hooks_dir.as_deref().and_then(Path::to_str) {
- overrides.push(("core.hooksPath", hooks));
- }
- overrides
-}
-
-// @relation(compat.cgi)
-/// Translate a CGI response (header block, blank line, body) into HTTP.
-fn build_response(stdout: &[u8]) -> Response {
- let (header_block, body) = match find_subsequence(stdout, CGI_HEADER_SEP) {
- Some(pos) => {
- let body_start = pos.saturating_add(CGI_HEADER_SEP.len());
- (
- stdout.get(..pos).unwrap_or_default(),
- stdout.get(body_start..).unwrap_or_default(),
- )
- }
- None => (&b""[..], stdout),
- };
-
- let mut status = 200u16;
- let mut builder = Response::builder();
- for raw in header_block.split(|byte| *byte == b'\n') {
- let line = trim_cr(raw);
- let Some(colon) = line.iter().position(|byte| *byte == b':') else {
- continue;
- };
- let name = line.get(..colon).unwrap_or_default();
- let value = trim_space(line.get(colon.saturating_add(1)..).unwrap_or_default());
- if name.eq_ignore_ascii_case(b"Status") {
- status = parse_status(value).unwrap_or(200);
- } else if name.eq_ignore_ascii_case(b"Content-Length") {
- // Axum sets this from the body length itself.
- } else {
- builder = builder.header(name, value);
- }
- }
-
- builder
- .status(status)
- .body(Body::from(body.to_vec()))
- .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
-}
-
-/// Greatest repository nesting depth: `repo`, `org/repo`, or `org/team/repo`.
-/// Shared by the push gateway and the web UI's routing/discovery.
-pub(crate) const MAX_REPO_DEPTH: usize = 3;
-
-// @relation(protocol.routing)
-/// Whether a GET should be answered with the HTML web UI rather than handed to
-/// `git http-backend`.
-///
-/// Anything that is not a git wire-protocol request (smart or dumb HTTP) is web.
-/// In addition, the browse routes (`/tree/`, `/blob/`, `/commit/`) are claimed
-/// for the web UI even when a file path within them happens to resemble a
-/// dumb-HTTP git path (e.g. a file named `HEAD`, or a directory named
-/// `objects`) — but never when it is an actual smart-HTTP service request, so a
-/// repository named `commit` can still be pushed to and cloned.
-fn is_web_get(path: &str, query: &str) -> bool {
- let is_wire =
- is_service_request(path, query) || path.ends_with("/HEAD") || path.contains("/objects/");
- let is_browse = path.contains("/tree/") || path.contains("/blob/") || path.contains("/commit/");
- !is_wire || (is_browse && !is_service_request(path, query))
-}
-
-// @relation(protocol.routing)
-/// Whether `path`/`query` is an unambiguous smart-HTTP service request (the
-/// ref advertisement or an upload-pack/receive-pack RPC).
-fn is_service_request(path: &str, query: &str) -> bool {
- path.ends_with("/info/refs")
- || path.ends_with("/git-upload-pack")
- || path.ends_with("/git-receive-pack")
- || query.contains("service=")
-}
-
-/// Whether this request is a push: the smart-HTTP receive-pack advertisement
-/// (`/info/refs?service=git-receive-pack`) or the receive-pack RPC itself.
-///
-/// The `service` parameter is matched exactly, so a request for the read-only
-/// `git-upload-pack` service (or an unrelated parameter that merely contains
-/// the string) is not mistaken for a push.
-fn is_receive_pack(path_info: &str, query: &str) -> bool {
- path_info.ends_with("/git-receive-pack")
- || (path_info.ends_with("/info/refs") && query_service(query) == Some("git-receive-pack"))
-}
-
-/// The value of the `service` query parameter, if present.
-pub(crate) fn query_service(query: &str) -> Option<&str> {
- query
- .split('&')
- .find_map(|pair| pair.strip_prefix("service="))
-}
-
-// @relation(namespace.auto-create, storage.bare)
-/// Ensure `repo` exists as a bare repository, creating it on first push.
-///
-/// Holds [`AppState::init_lock`] across the whole check-and-create so two
-/// concurrent first pushes to the same name cannot both initialize it, and
-/// refuses paths that collide with an existing repository: one nested inside a
-/// repo, or one that already exists as a namespace directory.
-pub(crate) async fn ensure_repo(state: &AppState, repo: &Path) -> Result<(), Response> {
- let _guard = state.init_lock.lock().await;
- if enclosing_repo(&state.data_dir, repo).is_some() {
- return Err((
- StatusCode::CONFLICT,
- "repository path is nested inside an existing repository",
- )
- .into_response());
- }
- if repo.exists() {
- return if is_bare_repo(repo) {
- Ok(())
- } else {
- Err((
- StatusCode::CONFLICT,
- "repository path already exists as a namespace",
- )
- .into_response())
- };
- }
- init_bare_repo(repo).await.map_err(|e| {
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("init failed: {e}"),
- )
- .into_response()
- })
-}
-
-// @relation(namespace.path)
-/// The ancestor of `repo` (below `data_dir`) that is itself a bare repository,
-/// if any. Used to refuse creating a repository inside another one.
-fn enclosing_repo(data_dir: &Path, repo: &Path) -> Option<PathBuf> {
- let relative = repo.strip_prefix(data_dir).ok()?;
- let mut current = data_dir.to_path_buf();
- let mut components = relative.components().peekable();
- while let Some(component) = components.next() {
- if components.peek().is_none() {
- break;
- }
- current.push(component);
- if is_bare_repo(¤t) {
- return Some(current);
- }
- }
- None
-}
-
-/// Whether `path` is the root of a bare git repository.
-pub(crate) fn is_bare_repo(path: &Path) -> bool {
- path.join("HEAD").is_file() && path.join("objects").is_dir()
-}
-
-// @relation(namespace.path)
-/// The target repository of a smart-HTTP service request (push or fetch),
-/// as a validated path relative to the data directory, or `None` if the
-/// request does not name an acceptable repository.
-///
-/// The repository is everything before git's service suffix (`/info/refs`,
-/// `/git-receive-pack`, or `/git-upload-pack`), limited to
-/// [`MAX_REPO_DEPTH`] segments each drawn from a conservative character
-/// set. Validating the segments here is what keeps a request from escaping
-/// the data directory or fabricating arbitrary paths on disk: every
-/// returned component is a plain, dot-free, separator-free name, so the
-/// join below can only ever descend into `data_dir`.
-fn repo_path(path_info: &str) -> Option<PathBuf> {
- let repo = path_info
- .strip_suffix("/git-receive-pack")
- .or_else(|| path_info.strip_suffix("/git-upload-pack"))
- .or_else(|| path_info.strip_suffix("/info/refs"))?;
- let segments: Vec<&str> = repo.split('/').filter(|s| !s.is_empty()).collect();
- if segments.is_empty() || segments.len() > MAX_REPO_DEPTH {
- return None;
- }
- if !segments.iter().all(|segment| valid_segment(segment)) {
- return None;
- }
- Some(segments.into_iter().collect())
-}
-
-// @relation(namespace.path)
-/// Whether a single path component is a safe repository/namespace name.
-///
-/// Rejecting any leading `.` rules out `.`, `..`, and hidden directories; the
-/// allow-list of characters rules out path separators, NUL, percent-encoding,
-/// and whitespace, so no segment can traverse or otherwise escape on disk.
-pub(crate) fn valid_segment(segment: &str) -> bool {
- !segment.is_empty()
- && !segment.starts_with('.')
- && segment
- .bytes()
- .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
-}
-
-// @relation(namespace.auto-create, compat.git)
-/// Create a bare repo that accepts pushes over smart-HTTP.
-async fn init_bare_repo(repo: &Path) -> std::io::Result<()> {
- let init = Command::new("git")
- .arg("init")
- .arg("--bare")
- .arg("-b")
- .arg("main")
- .arg(repo)
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .await?;
- if !init.success() {
- return Err(std::io::Error::other("git init --bare failed"));
- }
- let config = Command::new("git")
- .arg("-C")
- .arg(repo)
- .arg("config")
- .arg("http.receivepack")
- .arg("true")
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .await?;
- if !config.success() {
- return Err(std::io::Error::other("git config http.receivepack failed"));
- }
- Ok(())
-}
-
-// @relation(namespace.auto-create, compat.git)
-/// Point `HEAD` at a real branch when it dangles after a push.
-///
-/// Best-effort: the push already succeeded, so failures here are ignored.
-async fn reconcile_head(repo: &Path) {
- let head_valid = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["rev-parse", "--verify", "--quiet", "HEAD"])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .await
- .map(|status| status.success())
- .unwrap_or(false);
- if head_valid {
- return;
- }
-
- let Ok(output) = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["for-each-ref", "--format=%(refname:short)", "refs/heads/"])
- .stderr(Stdio::null())
- .output()
- .await
- else {
- return;
- };
- let branches = String::from_utf8_lossy(&output.stdout);
- let branches: Vec<&str> = branches.lines().filter(|line| !line.is_empty()).collect();
- let Some(branch) = branches
- .iter()
- .find(|name| **name == "main")
- .or_else(|| branches.iter().find(|name| **name == "master"))
- .or_else(|| branches.first())
- else {
- return;
- };
-
- let _set = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .await;
-}
-
-/// `relative`'s repository id, the way every hydration-mode component
-/// (this module, `git_hydrate::pre_receive`, `native_git`'s own resolver)
-/// names one: its data-dir-relative path with forward slashes, regardless
-/// of host path-separator conventions.
-fn repo_id_string(relative: &Path) -> String {
- relative.to_string_lossy().replace('\\', "/")
-}
-
-// @relation(protocol.git, storage.bare)
-/// WS0's read-path hydration step for one request: top up `repo_path`'s
-/// local packs from `hydrate`'s durable stores (idempotent — a no-op past
-/// the first pack a given ephemeral instance has already fetched) and
-/// regenerate its `packed-refs` from Postgres, bounding advertisement
-/// staleness to this one request (`docs/scale-out.adoc`, "WS0").
-///
-/// Runs on a blocking task: [`refstore_postgres::PostgresRefStore`] and
-/// [`odb_tigris::OdbTigris`] are synchronous (each owns its own dedicated
-/// runtime for the async clients underneath), so driving them straight from
-/// this async handler would block the executor thread they're called from.
-async fn hydrate_repo(
- hydrate: &git_hydrate::HydrateConfig,
- repo_path: &Path,
- repo_id: &str,
-) -> Result<(), Response> {
- let hydrate = hydrate.clone();
- let repo_path = repo_path.to_path_buf();
- let repo_id = repo_id.to_owned();
- let result = tokio::task::spawn_blocking(move || -> git_backend::Result<()> {
- let registry = refstore_postgres::PostgresRefStore::connect(
- &hydrate.postgres_conninfo,
- repo_id.clone(),
- )?;
- match &hydrate.blob {
- git_hydrate::config::BlobStore::Fs(root) => {
- let transport = odb_tigris::transport::fs::FsTransport::open(root)?;
- git_hydrate::hydrate::ensure_hydrated(&repo_path, &repo_id, &transport, ®istry)?;
- }
- git_hydrate::config::BlobStore::S3(s3) => {
- let transport = odb_tigris::transport::s3::S3Transport::connect(s3)?;
- git_hydrate::hydrate::ensure_hydrated(&repo_path, &repo_id, &transport, ®istry)?;
- }
- }
- let refs =
- refstore_postgres::PostgresRefStore::connect(&hydrate.postgres_conninfo, repo_id)?;
- git_hydrate::packed_refs::regenerate(&repo_path, &refs)?;
- Ok(())
- })
- .await;
- match result {
- Ok(Ok(())) => Ok(()),
- Ok(Err(error)) => Err((
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("hydration failed: {error}"),
- )
- .into_response()),
- Err(join_error) => Err((
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("hydration task panicked: {join_error}"),
- )
- .into_response()),
- }
-}
-
-fn header_value(headers: &HeaderMap, field: &str) -> Option<String> {
- headers
- .get(field)
- .and_then(|value| value.to_str().ok())
- .map(str::to_owned)
-}
-
-fn parse_status(value: &[u8]) -> Option<u16> {
- let token = value.split(|byte| *byte == b' ').next()?;
- std::str::from_utf8(token).ok()?.parse().ok()
-}
-
-fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
- if needle.is_empty() || haystack.len() < needle.len() {
- return None;
- }
- haystack
- .windows(needle.len())
- .position(|window| window == needle)
-}
-
-fn trim_cr(line: &[u8]) -> &[u8] {
- match line.split_last() {
- Some((b'\r', rest)) => rest,
- _ => line,
- }
-}
-
-fn trim_space(mut value: &[u8]) -> &[u8] {
- while let Some((first, rest)) = value.split_first() {
- if *first == b' ' || *first == b'\t' {
- value = rest;
- } else {
- break;
- }
- }
- while let Some((last, rest)) = value.split_last() {
- if *last == b' ' || *last == b'\t' {
- value = rest;
- } else {
- break;
- }
- }
- value
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use rstest::rstest;
-
- #[rstest]
- #[case("repo", true)]
- #[case("repo.git", true)]
- #[case("My-Repo_1.git", true)]
- #[case("", false)]
- #[case(".", false)]
- #[case("..", false)]
- #[case(".hidden", false)]
- #[case("a/b", false)]
- #[case("a b", false)]
- #[case("a%2eb", false)]
- // @relation(namespace.path, role=Verifies)
- fn validates_segments(#[case] segment: &str, #[case] expected: bool) {
- assert_eq!(valid_segment(segment), expected);
- }
-
- fn state(cert_nonce_seed: Option<&str>, hooks_dir: Option<&str>) -> AppState {
- AppState {
- data_dir: PathBuf::from("/data"),
- init_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())),
- cert_nonce_seed: cert_nonce_seed.map(str::to_owned),
- hooks_dir: hooks_dir.map(PathBuf::from),
- checks_queue: PathBuf::from("/data/checks-queue"),
- sessions: crate::web::new_sessions(),
- challenges: crate::web::new_challenges(),
- web_signing_key: None,
- live_runs: git_effect::engine::new_live_registry(),
- hydrate: None,
- maintenance: None,
- }
- }
-
- // @relation(auth.nonce, role=Verifies)
- #[test]
- fn backend_config_is_empty_without_authentication() {
- assert!(backend_config(&state(None, None)).is_empty());
- }
-
- // @relation(auth.nonce, role=Verifies)
- #[test]
- fn backend_config_injects_nonce_seed_and_hooks_path() {
- assert_eq!(
- backend_config(&state(Some("seed"), Some("/app/hooks"))),
- vec![
- ("receive.certNonceSeed", "seed"),
- ("receive.certNonceSlop", "60"),
- ("core.hooksPath", "/app/hooks"),
- ]
- );
- }
-
- #[rstest]
- #[case("/repo.git/git-receive-pack", Some("repo.git"))]
- #[case("/org/repo.git/git-receive-pack", Some("org/repo.git"))]
- #[case("/org/team/repo.git/git-receive-pack", Some("org/team/repo.git"))]
- #[case("/repo.git/info/refs", Some("repo.git"))]
- #[case("/a/b/c/d.git/git-receive-pack", None)]
- #[case("/../etc/git-receive-pack", None)]
- #[case("/.ssh/git-receive-pack", None)]
- #[case("/git-receive-pack", None)]
- // @relation(namespace.path, role=Verifies)
- fn extracts_repo_path(#[case] path: &str, #[case] expected: Option<&str>) {
- assert_eq!(repo_path(path).as_deref(), expected.map(Path::new));
- }
-
- #[rstest]
- #[case("/repo.git/git-receive-pack", "", true)]
- #[case("/repo.git/info/refs", "service=git-receive-pack", true)]
- #[case("/repo.git/info/refs", "service=git-upload-pack", false)]
- #[case("/repo.git/info/refs", "", false)]
- #[case("/repo.git/info/refs", "x=service=git-receive-pack", false)]
- #[case("/repo.git/info/refs", "a=b&service=git-receive-pack", true)]
- #[case("/repo.git/objects/info/packs", "", false)]
- // @relation(protocol.routing, role=Verifies)
- fn detects_pushes(#[case] path: &str, #[case] query: &str, #[case] expected: bool) {
- assert_eq!(is_receive_pack(path, query), expected);
- }
-
- #[rstest]
- // Plain browse pages and the index are always web.
- #[case("/", "", true)]
- #[case("/repo", "", true)]
- #[case("/repo/tree/src", "", true)]
- #[case("/repo/blob/src/main.rs", "", true)]
- #[case("/repo/commit/abc123", "", true)]
- // Browse routes win over the loose dumb-HTTP heuristics: a file named HEAD,
- // or a directory named `objects`, still renders as HTML.
- #[case("/repo/blob/HEAD", "", true)]
- #[case("/repo/blob/src/objects/mod.rs", "", true)]
- // Real smart-HTTP requests are never stolen, even for a repo named `commit`.
- #[case("/commit/info/refs", "service=git-upload-pack", false)]
- #[case("/commit/git-receive-pack", "", false)]
- #[case("/repo/info/refs", "service=git-upload-pack", false)]
- #[case("/repo/git-upload-pack", "", false)]
- #[case("/repo/objects/12/abcdef", "", false)]
- // @relation(protocol.routing, role=Verifies)
- fn routes_browser_gets(#[case] path: &str, #[case] query: &str, #[case] expected: bool) {
- assert_eq!(is_web_get(path, query), expected);
- }
-}
crates/git-ents-server/src/lib.rs
@@ -1,280 +1,0 @@
-//! Git Ents server — helpful guardians of your git trees.
-//!
-//! [`Args`] is `pub` so `git ents` can embed this server as its own `server`
-//! subcommand, alongside the standalone `git-ents-server` binary.
-
-mod asciidoc;
-mod http;
-mod markdown;
-mod native_git;
-/// MIME-keyed document rendering (HTML and plain-text), shared by the web
-/// UI and the `git-ents` CLI, which embeds this crate as a library.
-pub mod render;
-mod web;
-
-use std::net::SocketAddr;
-use std::path::PathBuf;
-use std::process::ExitCode;
-use std::sync::Arc;
-
-use axum::Router;
-use axum::extract::DefaultBodyLimit;
-use axum::routing::get;
-use facet::Facet;
-use figue::{self as args};
-use tokio::sync::Mutex;
-
-/// Command-line arguments, layered over the matching environment variables
-/// (the flag always wins) since that is how this server is configured on
-/// Fly.io.
-#[derive(Facet, Debug)]
-pub struct Args {
- /// Subcommand that runs instead of serving HTTP.
- #[facet(args::subcommand)]
- pub command: Option<Command>,
-
- /// Port to listen on ($PORT, default 8080).
- #[facet(args::named)]
- pub port: Option<u16>,
-
- /// Directory holding the bare repositories served over HTTP
- /// ($GIT_PROJECT_ROOT, default `/data/repos`).
- #[facet(args::named)]
- pub data_dir: Option<PathBuf>,
-
- /// Secret seed for signed-push nonces ($CERT_NONCE_SEED). Setting it
- /// requires pushes to carry a signed-push certificate, enabling
- /// authentication against the signers.
- #[facet(args::named)]
- pub cert_nonce_seed: Option<String>,
-
- /// Directory of git hooks (a `pre-receive`) applied to every served repo
- /// ($GIT_ENTS_HOOKS_DIR).
- #[facet(args::named)]
- pub hooks_dir: Option<PathBuf>,
-
- /// The server's own SSH private key, used to sign browser-made edits
- /// ($GIT_ENTS_WEB_SIGNING_KEY). Its public half must be a member of any
- /// repo edited through the web. Editing is disabled unless this is set.
- #[facet(args::named)]
- pub web_signing_key: Option<PathBuf>,
-
- /// Directory where the `post-receive` hook queues pushes for the check
- /// worker to run asynchronously ($GIT_ENTS_CHECKS_QUEUE, default
- /// `/data/checks-queue`).
- #[facet(args::named)]
- pub checks_queue: Option<PathBuf>,
-}
-
-// @relation(server.embeddable)
-/// Subcommands that run instead of serving HTTP.
-#[derive(Facet, Debug)]
-#[repr(u8)]
-pub enum Command {
- /// Verify a signed push against the authorized signers (a git
- /// `pre-receive` hook).
- PreReceive,
- /// Run the configured checks against a push in a Sprite (a git
- /// `post-receive` hook).
- PostReceive,
-}
-
-/// Shared handler state: where the bare repositories live, plus a lock that
-/// serializes repository creation so concurrent first pushes cannot race.
-#[derive(Clone)]
-pub(crate) struct AppState {
- pub(crate) data_dir: PathBuf,
- pub(crate) init_lock: Arc<Mutex<()>>,
- /// When set, injected as `receive.certNonceSeed` so the backend demands a
- /// signed-push certificate the `pre-receive` hook can verify.
- pub(crate) cert_nonce_seed: Option<String>,
- /// When set, injected as `core.hooksPath` so every served repo runs the
- /// bundled `pre-receive` verifier.
- pub(crate) hooks_dir: Option<PathBuf>,
- /// Directory the `post-receive` hook queues pushes into and the check
- /// worker drains; passed down to the hook via [`git_effect::engine::QUEUE_ENV`].
- pub(crate) checks_queue: PathBuf,
- /// In-memory web sessions: a browser's signed-in public key, held for the
- /// life of the process and never persisted.
- pub(crate) sessions: web::Sessions,
- /// Outstanding one-time sign-in challenges awaiting a signature.
- pub(crate) challenges: web::Challenges,
- /// The server's own signing key for browser-made edits; `None` disables
- /// editing.
- pub(crate) web_signing_key: Option<PathBuf>,
- /// Live output for checks the worker currently has running, polled by the
- /// Checks tab's live view.
- pub(crate) live_runs: git_effect::engine::LiveRegistry,
- /// WS0 hydration config (`docs/scale-out.adoc`, "WS0 — Interim
- /// hydration backend"), read once at startup from the environment
- /// (see [`git_hydrate::HydrateConfig::from_env`]). `None` keeps this
- /// deployment on the current direct-disk behavior; `Some` hydrates
- /// every served repo from Postgres/blob-store durable state on every
- /// request, per `crate::http`'s wiring.
- pub(crate) hydrate: Option<git_hydrate::HydrateConfig>,
- /// WS9 maintenance scheduler (`docs/scale-out.adoc`, "WS9 — GC,
- /// compaction, maintenance"): accumulates per-repo ref-update volume
- /// from accepted pushes (see `native_git::receive_pack`, the wired
- /// call site) and enqueues the maintenance effects once a repo
- /// crosses the threshold. Present only when hydration (and so the
- /// Postgres effect queue the effects are enqueued into) is
- /// configured; a direct-disk deployment has no queue to schedule
- /// into yet.
- pub(crate) maintenance: Option<Arc<git_maintenance::schedule::Scheduler>>,
-}
-
-/// The non-empty value of the environment variable `key`, or `None`.
-fn env_var(key: &str) -> Option<String> {
- std::env::var(key).ok().filter(|value| !value.is_empty())
-}
-
-// @relation(server.embeddable, deploy.fly)
-/// Run the server: dispatch `pre-receive`/`post-receive`, or serve HTTP.
-/// `args`' flags win over their matching environment variable, which in turn
-/// wins over the hardcoded default.
-pub fn run(args: Args) -> ExitCode {
- if let Some(Command::PreReceive) = args.command {
- // Hydration mode (`docs/scale-out.adoc`, WS0): `crate::http`'s
- // backend invocation injects `GIT_ENTS_HYDRATE_*` env vars onto
- // every `git http-backend` it spawns whenever `AppState::hydrate`
- // is configured, and they propagate down to this hook exactly as
- // `GIT_ENTS_HOOKS_DIR`/`GIT_ENTS_CHECKS_QUEUE` already do. Their
- // presence here is this (separate) hook process's only way to
- // learn hydration is on; there is no shared `AppState` to consult.
- if let Some(hydrate) = git_hydrate::HydrateConfig::from_env() {
- let signing_key = env_var("GIT_ENTS_WEB_SIGNING_KEY").map(PathBuf::from);
- return match git_hydrate::pre_receive::run(&hydrate, signing_key.as_deref()) {
- Ok(()) => ExitCode::SUCCESS,
- Err(reason) => {
- eprintln!("error: {reason}");
- ExitCode::FAILURE
- }
- };
- }
- return match git_signed_push::pre_receive() {
- Ok(()) => ExitCode::SUCCESS,
- Err(reason) => {
- eprintln!("error: {reason}");
- ExitCode::FAILURE
- }
- };
- }
-
- if let Some(Command::PostReceive) = args.command {
- // A post-receive failure cannot undo the push; report and exit clean so
- // a runner hiccup never looks like a rejected push.
- if let Err(reason) = git_effect::engine::post_receive() {
- eprintln!("effects: {reason}");
- }
- return ExitCode::SUCCESS;
- }
-
- let runtime = match tokio::runtime::Runtime::new() {
- Ok(runtime) => runtime,
- Err(e) => {
- eprintln!("error: failed to start runtime: {e}");
- return ExitCode::FAILURE;
- }
- };
- runtime.block_on(serve(args))
-}
-
-/// Bind the listener and serve until shutdown.
-async fn serve(args: Args) -> ExitCode {
- let port = args
- .port
- .or_else(|| env_var("PORT").and_then(|value| value.parse().ok()))
- .unwrap_or(8080);
- let data_dir = args
- .data_dir
- .or_else(|| env_var("GIT_PROJECT_ROOT").map(PathBuf::from))
- .unwrap_or_else(|| PathBuf::from("/data/repos"));
- let checks_queue = args
- .checks_queue
- .or_else(|| env_var("GIT_ENTS_CHECKS_QUEUE").map(PathBuf::from))
- .unwrap_or_else(|| PathBuf::from("/data/checks-queue"));
- let cert_nonce_seed = args.cert_nonce_seed.or_else(|| env_var("CERT_NONCE_SEED"));
- let hooks_dir = args
- .hooks_dir
- .or_else(|| env_var("GIT_ENTS_HOOKS_DIR").map(PathBuf::from));
- let web_signing_key = args
- .web_signing_key
- .or_else(|| env_var("GIT_ENTS_WEB_SIGNING_KEY").map(PathBuf::from));
- // @relation(protocol.routing)
- // Read once at startup, exactly as `git_hydrate::pre_receive`'s hook
- // process reads it again for itself (see `Command::PreReceive` above)
- // — the one config both this process and every hook subprocess it
- // spawns must agree on.
- let hydrate = git_hydrate::HydrateConfig::from_env();
-
- // WS9: schedule maintenance on ref-update volume (`docs/scale-out.adoc`,
- // "Reachability": "triggered by ref-update volume thresholds"), into
- // the same Postgres effect queue the WS7 dispatcher drains.
- let maintenance = hydrate.as_ref().map(|config| {
- Arc::new(git_maintenance::schedule::Scheduler::new(
- git_maintenance::schedule::Thresholds::default(),
- Arc::new(git_maintenance::schedule::PostgresQueueSink::new(
- config.postgres_conninfo.clone(),
- )),
- ))
- });
-
- let state = AppState {
- data_dir,
- init_lock: Arc::new(Mutex::new(())),
- cert_nonce_seed,
- hooks_dir,
- checks_queue,
- sessions: web::new_sessions(),
- challenges: web::new_challenges(),
- web_signing_key,
- live_runs: git_effect::engine::new_live_registry(),
- hydrate,
- maintenance,
- };
-
- // Drain queued pushes and run their effects for the life of the server:
- // the Sprite backend when `SPRITES_TOKEN` says this is the hosted
- // deployment, the local Docker backend otherwise — see
- // `git_effect::engine::default_backend`.
- tokio::spawn(git_effect::engine::worker(
- state.checks_queue.clone(),
- state.live_runs.clone(),
- git_effect::engine::default_backend(),
- ));
-
- // @relation(protocol.routing, deploy.health)
- // The git smart-HTTP protocol streams whole packfiles through the request
- // body, so the default 2 MiB cap would reject any non-trivial push.
- let app = Router::new()
- .route("/healthz", get(http::health))
- .route("/", get(http::get_request))
- // @relation(checks.debug)
- .route("/_debug/{*path}", get(web::handshake))
- // The native protocol-trait smart-HTTP path (WS3), additive
- // alongside the `git http-backend` CGI gateway below — see
- // `native_git`'s module doc comment.
- .route(
- "/_native/{*path}",
- get(native_git::get_request).post(native_git::post_request),
- )
- .route("/{*path}", get(http::get_request).post(http::post_request))
- .layer(DefaultBodyLimit::disable())
- .with_state(state);
-
- let addr = SocketAddr::from(([0, 0, 0, 0], port));
- let listener = match tokio::net::TcpListener::bind(addr).await {
- Ok(listener) => listener,
- Err(e) => {
- eprintln!("error: failed to bind to port {port}: {e}");
- return ExitCode::FAILURE;
- }
- };
-
- if let Err(e) = axum::serve(listener, app).await {
- eprintln!("error: {e}");
- return ExitCode::FAILURE;
- }
-
- ExitCode::SUCCESS
-}
crates/git-ents-server/src/main.rs
@@ -1,69 +1,0 @@
-//! `git-ents-server` — the standalone binary; see [`git_ents_server`] for the
-//! `pub` `Args`/`Command` also embedded as `git ents server`.
-
-use std::process::ExitCode;
-
-use facet::Facet;
-use figue::FigueBuiltins;
-
-#[derive(Facet)]
-struct Cli {
- #[facet(flatten)]
- args: git_ents_server::Args,
- #[facet(flatten)]
- builtins: FigueBuiltins,
-}
-
-/// ## Requirements
-///
-/// @relation(server.embeddable)
-fn main() -> ExitCode {
- let raw_args: Vec<String> = std::env::args().skip(1).collect();
-
- // @relation(deploy.fly)
- // With zero CLI tokens (how Fly.io always runs this image — every setting
- // arrives via env vars, which `git_ents_server::run` reads itself),
- // `#[facet(flatten)]`'s all-`Option` `args` never gets a single populated
- // key, and figue reports the whole flattened group as a missing field
- // rather than an all-`None` value. Build it directly instead of parsing.
- if raw_args.is_empty() {
- return git_ents_server::run(git_ents_server::Args {
- command: None,
- port: None,
- data_dir: None,
- cert_nonce_seed: None,
- hooks_dir: None,
- web_signing_key: None,
- checks_queue: None,
- });
- }
-
- let config = match figue::builder::<Cli>() {
- Ok(builder) => builder,
- Err(error) => {
- eprintln!("{error}");
- return ExitCode::FAILURE;
- }
- }
- .cli(|cli| cli.args(raw_args))
- .help(|help| {
- help.program_name("git-ents-server")
- .version(env!("CARGO_PKG_VERSION"))
- })
- .build();
- let cli: Cli = match figue::Driver::new(config).run().into_result() {
- Ok(output) => output.get(),
- Err(figue::DriverError::Help {
- text,
- suggestion: suggestion @ Some(_),
- }) => {
- println!("{text}");
- if let Some(s) = suggestion {
- println!("{}", s.render_pretty());
- }
- return ExitCode::FAILURE;
- }
- Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(),
- };
- git_ents_server::run(cli.args)
-}
crates/git-ents-server/src/markdown.rs
@@ -1,34 +1,0 @@
-//! Markdown rendering via [`pulldown_cmark`].
-//!
-//! Markdown gets the same treatment AsciiDoc does: a repository's `README.md`
-//! renders as the editorial centerpiece of the overview, and `.md` blobs as
-//! formatted documents rather than highlighted source. Output is an embedded
-//! fragment (no document frame) styled by the page's own stylesheet.
-
-use pulldown_cmark::{Options, Parser, html};
-
-/// File extensions that name a Markdown document.
-const EXTENSIONS: [&str; 4] = ["md", "markdown", "mdown", "mkd"];
-
-/// Whether `name` looks like a Markdown file by its extension.
-pub(crate) fn is_markdown(name: &str) -> bool {
- name.rsplit_once('.')
- .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
-}
-
-/// Render Markdown `source` to an embedded HTML fragment, with the tables,
-/// footnotes, strikethrough, and task-list extensions people expect from
-/// forge-flavored Markdown.
-///
-/// ## Requirements
-///
-/// @relation(web.render-registry, web.syntax-highlight)
-pub(crate) fn to_html(source: &str) -> String {
- let options = Options::ENABLE_TABLES
- | Options::ENABLE_FOOTNOTES
- | Options::ENABLE_STRIKETHROUGH
- | Options::ENABLE_TASKLISTS;
- let mut out = String::new();
- html::push_html(&mut out, Parser::new_ext(source, options));
- out
-}
crates/git-ents-server/src/native_git.rs
@@ -1,603 +1,0 @@
-//! Smart-HTTP through the native `git-protocol` traits (WS3), mounted
-//! additively under `/_native/` alongside the existing `git http-backend`
-//! CGI gateway (`crate::http`).
-//!
-//! `docs/scale-out.adoc`'s "Protocol traits" section explicitly permits more
-//! than one conforming implementation behind `Advertise`/`Negotiate`/
-//! `GeneratePack`/`IngestPack` — "whether that beats the native
-//! implementation is empirical, settled by conformance plus cost, not by
-//! fiat." `crate::http`'s CGI gateway already *is* the stock-git-wrapped
-//! backend the plan describes as WS0, shipped first and load-bearing (hooks,
-//! the checks queue, signed-push nonces); replacing it outright to satisfy
-//! WS3 would be the larger, riskier change for no correctness gain over
-//! mounting the native path beside it. This module is that native path,
-//! wired end-to-end: `GET .../info/refs` and `POST .../git-upload-pack`
-//! serve a stock `git clone`/`fetch` with zero client configuration, and
-//! `POST .../git-receive-pack` ingests real pushes — including a stock
-//! `git push --signed` (`push.gpgSign`) — through
-//! [`git_protocol::IngestPack`], the same staged-then-atomic-then-promoted
-//! ordering and attestation check the unit tests in `git-protocol`
-//! exercise directly.
-//!
-//! The push-certificate wire protocol: the receive-pack advertisement
-//! carries a `push-cert=<nonce>` capability; a signing client answers with
-//! a `push-cert` pkt-line block in place of the plain command list —
-//! certificate header (echoing the nonce), the commands themselves, and an
-//! SSH signature — which [`parse_receive_request`] reassembles into the
-//! exact payload the client signed. The nonce is session-scoped
-//! anti-replay, never durable state (`docs/scale-out.adoc`, "Attested
-//! push"): it is a keyed hash of a per-process secret, the repository, and
-//! a timestamp, verified by recomputation within a slop window rather than
-//! by storing anything.
-
-use std::path::PathBuf;
-use std::sync::{Arc, OnceLock};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use axum::body::Bytes;
-use axum::extract::State;
-use axum::http::{HeaderMap, StatusCode, Uri};
-use axum::response::{IntoResponse, Response};
-use git_backend::{Expected, PackStream, RefEdit, RefName};
-use git_protocol::native::{BackendResolver, NativeBackend, RepoBackends};
-use git_protocol::{
- AdSpec, Advertise as _, GeneratePack as _, IngestPack as _, Negotiate as _, NegotiationState,
- PushCertificate, PushRequest, RepoId,
-};
-use gix_hash::ObjectId;
-
-use crate::AppState;
-
-/// Resolves a [`RepoId`] to `refstore-files`/`odb-files` backends opened
-/// against `data_dir.join(repo)`, and to that repository's currently
-/// enrolled members/config — loaded fresh per call, exactly what
-/// `pre-receive` does, so both write paths see the identical trust set.
-struct DiskResolver {
- data_dir: PathBuf,
-}
-
-impl BackendResolver for DiskResolver {
- fn resolve(&self, repo: &RepoId) -> git_protocol::Result<RepoBackends> {
- let path = self.data_dir.join(repo.as_str());
- let refs = refstore_files::FilesRefStore::open(&path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let objects = odb_files::OdbFiles::open(&path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let members = git_member::members::load_all(&path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let revoked = git_member::revocations::fingerprints(&path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let config = git_ents_core::config::load(&path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- Ok(RepoBackends {
- refs: Arc::new(refs),
- objects: Arc::new(objects),
- authorized_members: git_member::members::without_revoked(members, &revoked),
- config,
- // No reachability artifacts for the disk-hydrated backend yet:
- // `gix-reachability`'s maintenance effect and artifact storage
- // target the cloud stack (`odb-tigris` + its pack registry);
- // wiring generation/loading for this resolver is future work,
- // and negotiation/ingest degrade to the plain walk in the
- // meantime (`docs/scale-out.adoc`: "absence ... degrades
- // speed, never answers").
- reachability: gix_reachability::ArtifactBundle::empty(),
- })
- }
-}
-
-fn backend(state: &AppState) -> NativeBackend<DiskResolver> {
- let signer: Arc<dyn git_protocol::attestation::OpSigner> = match &state.web_signing_key {
- Some(key) => Arc::new(git_protocol::attestation::SshOpSigner::new(key.clone())),
- // No server signing key configured: op records fail to sign, so
- // every otherwise-acceptable push over this endpoint is rejected —
- // fail-closed, since an accepted push without its op record would
- // break the "universal server op record" rule. Reads are
- // unaffected.
- None => Arc::new(git_protocol::attestation::SshOpSigner::new(PathBuf::from(
- "/dev/null",
- ))),
- };
- NativeBackend::new(
- DiskResolver {
- data_dir: state.data_dir.clone(),
- },
- signer,
- )
-}
-
-/// Serve `GET /_native/<repo>/info/refs?service=<git-upload-pack|git-receive-pack>`.
-pub async fn get_request(State(state): State<AppState>, uri: Uri) -> Response {
- let Some((repo_rel, "info/refs")) = split(uri.path()) else {
- return (StatusCode::NOT_FOUND, "not found").into_response();
- };
- let query = uri.query().unwrap_or_default();
- let service = crate::http::query_service(query).unwrap_or("git-upload-pack");
- if service != "git-upload-pack" && service != "git-receive-pack" {
- return (StatusCode::BAD_REQUEST, "unknown service").into_response();
- }
- let receive = service == "git-receive-pack";
-
- let repo_path = state.data_dir.join(repo_rel);
- if receive {
- if let Err(response) = crate::http::ensure_repo(&state, &repo_path).await {
- return response;
- }
- } else if !crate::http::is_bare_repo(&repo_path) {
- return (StatusCode::NOT_FOUND, "not found").into_response();
- }
-
- let ad = match backend(&state).refs(&RepoId::new(repo_rel), &AdSpec::everything()) {
- Ok(ad) => ad,
- Err(error) => {
- return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
- }
- };
-
- let nonce = if receive { issue_nonce(repo_rel) } else { None };
- let mut body = pkt_line(format!("# service={service}\n").as_bytes());
- body.extend_from_slice(FLUSH_PKT);
- body.extend(advertisement_lines(&ad, receive, nonce.as_deref()));
-
- Response::builder()
- .header(
- "Content-Type",
- format!("application/x-{service}-advertisement"),
- )
- .header("Cache-Control", "no-cache")
- .body(axum::body::Body::from(body))
- .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
-}
-
-/// Serve `POST /_native/<repo>/git-upload-pack` or `.../git-receive-pack`.
-pub async fn post_request(
- State(state): State<AppState>,
- uri: Uri,
- _headers: HeaderMap,
- body: Bytes,
-) -> Response {
- match split(uri.path()) {
- Some((repo_rel, "git-upload-pack")) => upload_pack(&state, repo_rel, &body).await,
- Some((repo_rel, "git-receive-pack")) => receive_pack(&state, repo_rel, &body).await,
- _ => (StatusCode::NOT_FOUND, "not found").into_response(),
- }
-}
-
-async fn upload_pack(state: &AppState, repo_rel: &str, body: &[u8]) -> Response {
- let (wants, haves) = parse_upload_request(body);
- let backend = backend(state);
- let mut session = NegotiationState {
- repo: RepoId::new(repo_rel),
- wants,
- haves,
- };
- let plan = match backend.wants_haves(&mut session) {
- Ok(plan) => plan,
- Err(error) => {
- return (StatusCode::BAD_REQUEST, error.to_string()).into_response();
- }
- };
- let mut stream = match backend.stream(&plan) {
- Ok(stream) => stream,
- Err(error) => {
- return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
- }
- };
- let mut pack_bytes = Vec::new();
- if let Err(error) = std::io::Read::read_to_end(&mut stream, &mut pack_bytes) {
- return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
- }
-
- let mut out = pkt_line(b"NAK\n");
- out.extend(pack_bytes);
- Response::builder()
- .header("Content-Type", "application/x-git-upload-pack-result")
- .body(axum::body::Body::from(out))
- .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
-}
-
-async fn receive_pack(state: &AppState, repo_rel: &str, body: &[u8]) -> Response {
- let (commands, pack) = split_commands(body);
- let parsed = parse_receive_request(&commands);
- let names: Vec<RefName> = parsed
- .ref_edits
- .iter()
- .map(|edit| edit.name.clone())
- .collect();
-
- // The nonce echo check — transport-level anti-replay, ahead of the
- // signature/authorization checks IngestPack::receive itself makes. A
- // certificate whose nonce is not one this process recently issued for
- // this repository is a replayed (or cross-repo) certificate, rejected
- // before anything is staged.
- if parsed.cert.is_some() {
- let nonce_ok = parsed
- .nonce
- .as_deref()
- .is_some_and(|nonce| nonce_valid(repo_rel, nonce));
- if !nonce_ok {
- return report_status(
- &names,
- &Ok(git_protocol::PushOutcome::Rejected {
- reason: "push certificate nonce was missing or stale".to_owned(),
- }),
- );
- }
- }
-
- let push = PushRequest {
- repo: RepoId::new(repo_rel),
- ref_edits: parsed.ref_edits,
- pack: PackStream::new(std::io::Cursor::new(pack.to_vec())),
- push_cert: parsed.cert.map(PushCertificate::new),
- };
- let outcome = backend(state).receive(push);
-
- // WS9's wired call site: an accepted push reports its ref-update
- // volume to the maintenance scheduler, which enqueues the maintenance
- // effects once the repo crosses its threshold (`docs/scale-out.adoc`,
- // "Reachability" / WS9). Off the request path (`spawn_blocking` — the
- // sink opens a Postgres connection when the threshold trips) and
- // never able to fail the push: scheduling errors are logged, the next
- // accepted push re-triggers.
- if matches!(outcome, Ok(git_protocol::PushOutcome::Accepted { .. }))
- && let Some(scheduler) = state.maintenance.clone()
- {
- let repo_id = repo_rel.to_owned();
- let updates = names.len() as u64;
- drop(tokio::task::spawn_blocking(move || {
- if let Err(error) = scheduler.note_ref_updates(&repo_id, updates) {
- eprintln!("maintenance: could not schedule for {repo_id}: {error}");
- }
- }));
- }
-
- report_status(&names, &outcome)
-}
-
-/// The report-status response for one push: `unpack ok`, then one `ok`/`ng`
-/// line per ref.
-fn report_status(
- names: &[RefName],
- outcome: &git_protocol::Result<git_protocol::PushOutcome>,
-) -> Response {
- let mut out = pkt_line(b"unpack ok\n");
- match outcome {
- Ok(git_protocol::PushOutcome::Accepted { .. }) => {
- for name in names {
- out.extend(pkt_line(format!("ok {name}\n").as_bytes()));
- }
- }
- Ok(git_protocol::PushOutcome::Rejected { reason }) => {
- for name in names {
- out.extend(pkt_line(format!("ng {name} {reason}\n").as_bytes()));
- }
- }
- Err(error) => {
- for name in names {
- out.extend(pkt_line(format!("ng {name} {error}\n").as_bytes()));
- }
- }
- }
- out.extend_from_slice(FLUSH_PKT);
- Response::builder()
- .header("Content-Type", "application/x-git-receive-pack-result")
- .body(axum::body::Body::from(out))
- .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
-}
-
-/// Split `/_native/<repo>/<suffix>` into `(repo, suffix)` for `suffix` in
-/// `{"info/refs", "git-upload-pack", "git-receive-pack"}`, validating every
-/// repo path segment exactly as the CGI gateway does.
-fn split(path: &str) -> Option<(&str, &str)> {
- let rest = path.strip_prefix("/_native/")?;
- for suffix in ["info/refs", "git-upload-pack", "git-receive-pack"] {
- if let Some(repo) = rest.strip_suffix(suffix) {
- let repo = repo.strip_suffix('/')?;
- let segments: Vec<&str> = repo.split('/').filter(|s| !s.is_empty()).collect();
- if segments.is_empty()
- || segments.len() > crate::http::MAX_REPO_DEPTH
- || !segments
- .iter()
- .all(|segment| crate::http::valid_segment(segment))
- {
- return None;
- }
- return Some((repo, suffix));
- }
- }
- None
-}
-
-const FLUSH_PKT: &[u8] = b"0000";
-
-fn pkt_line(data: &[u8]) -> Vec<u8> {
- let mut out = format!("{:04x}", data.len().saturating_add(4)).into_bytes();
- out.extend_from_slice(data);
- out
-}
-
-/// Every pkt-line in `body`, in order, skipping flush markers — correct for
-/// `git-upload-pack`'s request, which is pkt-lines from start to end with no
-/// trailing binary payload.
-fn pkt_lines(mut body: &[u8]) -> Vec<Vec<u8>> {
- let mut out = Vec::new();
- while body.len() >= 4 {
- let Ok(len_str) = std::str::from_utf8(body.get(..4).unwrap_or_default()) else {
- break;
- };
- let Ok(len) = usize::from_str_radix(len_str, 16) else {
- break;
- };
- if len == 0 {
- body = body.get(4..).unwrap_or_default();
- continue;
- }
- if len < 4 || body.len() < len {
- break;
- }
- out.push(body.get(4..len).unwrap_or_default().to_vec());
- body = body.get(len..).unwrap_or_default();
- }
- out
-}
-
-/// Pkt-line commands up to (and past) the first flush, and the raw bytes
-/// remaining after it — `git-receive-pack`'s request is pkt-line commands
-/// followed by a flush, then the pack as an unframed byte stream.
-fn split_commands(body: &[u8]) -> (Vec<Vec<u8>>, &[u8]) {
- let mut commands = Vec::new();
- let mut offset = 0usize;
- while offset.saturating_add(4) <= body.len() {
- let Some(len_hex) = body.get(offset..offset.saturating_add(4)) else {
- break;
- };
- let Ok(len_str) = std::str::from_utf8(len_hex) else {
- break;
- };
- let Ok(len) = usize::from_str_radix(len_str, 16) else {
- break;
- };
- if len == 0 {
- offset = offset.saturating_add(4);
- break;
- }
- if len < 4 {
- break;
- }
- let Some(end) = offset.checked_add(len).filter(|end| *end <= body.len()) else {
- break;
- };
- commands.push(
- body.get(offset.saturating_add(4)..end)
- .unwrap_or_default()
- .to_vec(),
- );
- offset = end;
- }
- (commands, body.get(offset..).unwrap_or_default())
-}
-
-fn parse_upload_request(body: &[u8]) -> (Vec<ObjectId>, Vec<ObjectId>) {
- let mut wants = Vec::new();
- let mut haves = Vec::new();
- for line in pkt_lines(body) {
- let text = String::from_utf8_lossy(&line);
- let text = text.trim_end_matches('\n');
- if let Some(rest) = text.strip_prefix("want ") {
- if let Some(hex) = rest.split_whitespace().next()
- && let Ok(oid) = ObjectId::from_hex(hex.as_bytes())
- {
- wants.push(oid);
- }
- } else if let Some(hex) = text.strip_prefix("have ")
- && let Ok(oid) = ObjectId::from_hex(hex.as_bytes())
- {
- haves.push(oid);
- }
- }
- (wants, haves)
-}
-
-fn parse_command(line: &[u8]) -> Option<RefEdit> {
- let text = String::from_utf8_lossy(line);
- let text = text.trim_end_matches('\n');
- // The first command carries a NUL-separated capability list.
- let text = text.split('\0').next().unwrap_or(text);
- let mut parts = text.split_whitespace();
- let old_hex = parts.next()?;
- let new_hex = parts.next()?;
- let name = parts.next()?;
- let old = ObjectId::from_hex(old_hex.as_bytes()).ok()?;
- let new = ObjectId::from_hex(new_hex.as_bytes()).ok()?;
- let null = ObjectId::null(gix_hash::Kind::Sha1);
- Some(RefEdit {
- name: RefName::new(name),
- expected: if old == null {
- Expected::MustNotExist
- } else {
- Expected::MustExistAndMatch(old)
- },
- new: (new != null).then_some(new),
- })
-}
-
-/// A parsed receive-pack request: the ref edits it asks for, plus — when
-/// the client answered the `push-cert` capability — the reassembled
-/// certificate text (exactly the bytes the client signed, then its
-/// signature) and the nonce it echoed.
-struct ParsedReceive {
- ref_edits: Vec<RefEdit>,
- cert: Option<String>,
- nonce: Option<String>,
-}
-
-/// Parse a receive-pack request's pkt-lines: either a plain command list,
-/// or a `push-cert` block (`pack-protocol.txt`: a `push-cert` line carrying
-/// the client's capabilities, the certificate header ending at a blank
-/// line, the command lines, the SSH signature block, then
-/// `push-cert-end`). With a certificate, the commands inside it are the
-/// authoritative ones — a client using `push-cert` sends no plain command
-/// list at all.
-fn parse_receive_request(commands: &[Vec<u8>]) -> ParsedReceive {
- let first_is_cert = commands.first().is_some_and(|line| {
- String::from_utf8_lossy(line)
- .split('\0')
- .next()
- .unwrap_or_default()
- .trim_end()
- == "push-cert"
- });
- if !first_is_cert {
- return ParsedReceive {
- ref_edits: commands
- .iter()
- .filter_map(|line| parse_command(line))
- .collect(),
- cert: None,
- nonce: None,
- };
- }
-
- let mut cert = String::new();
- let mut nonce = None;
- let mut ref_edits = Vec::new();
- let mut past_header = false;
- let mut in_signature = false;
- for line in commands.iter().skip(1) {
- let text = String::from_utf8_lossy(line);
- if text.trim_end() == "push-cert-end" {
- break;
- }
- // Certificate pkt-lines carry their own LF; concatenating them
- // as-is reconstructs the exact payload the client signed.
- cert.push_str(&text);
- let trimmed = text.trim_end_matches('\n');
- if !past_header {
- if let Some(value) = trimmed.strip_prefix("nonce ") {
- nonce = Some(value.to_owned());
- }
- if trimmed.is_empty() {
- past_header = true;
- }
- continue;
- }
- if trimmed.starts_with("-----BEGIN") {
- in_signature = true;
- }
- if !in_signature && let Some(edit) = parse_command(line) {
- ref_edits.push(edit);
- }
- }
- ParsedReceive {
- ref_edits,
- cert: Some(cert),
- nonce,
- }
-}
-
-/// How long an issued nonce stays echoable. The advertisement and the push
-/// are two HTTP requests seconds apart; anything older is a replay. The
-/// nonce is session-scoped anti-replay only, never durable state.
-const NONCE_SLOP_SECONDS: u64 = 300;
-
-/// The per-process secret nonces are keyed with. Process-scoped on
-/// purpose: a nonce must survive exactly the advertisement→push window
-/// within one server process, nothing longer.
-fn nonce_secret() -> &'static str {
- static SECRET: OnceLock<String> = OnceLock::new();
- SECRET.get_or_init(|| uuid::Uuid::new_v4().to_string())
-}
-
-fn unix_now() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|elapsed| elapsed.as_secs())
- .unwrap_or_default()
-}
-
-/// The keyed hash binding a nonce to this process, `repo`, and `stamp`.
-/// The secret is prepended, so recomputing it requires holding the secret.
-fn nonce_mac(repo: &str, stamp: u64) -> Option<String> {
- let data = format!("{}:{repo}:{stamp}", nonce_secret());
- gix_object::compute_hash(
- gix_hash::Kind::Sha1,
- gix_object::Kind::Blob,
- data.as_bytes(),
- )
- .ok()
- .map(|oid| oid.to_hex().to_string())
-}
-
-/// A fresh nonce for `repo`, advertised as `push-cert=<nonce>` and expected
-/// back in the certificate's `nonce` header.
-fn issue_nonce(repo: &str) -> Option<String> {
- let stamp = unix_now();
- nonce_mac(repo, stamp).map(|mac| format!("{stamp}-{mac}"))
-}
-
-/// Whether `nonce` is one this process issued for `repo` within the slop
-/// window — verified by recomputation, storing nothing.
-fn nonce_valid(repo: &str, nonce: &str) -> bool {
- let Some((stamp, mac)) = nonce.split_once('-') else {
- return false;
- };
- let Ok(stamp) = stamp.parse::<u64>() else {
- return false;
- };
- nonce_mac(repo, stamp).as_deref() == Some(mac)
- && unix_now().saturating_sub(stamp) <= NONCE_SLOP_SECONDS
-}
-
-/// The advertised ref lines: `HEAD` first (carrying capabilities and, when
-/// resolved, a `symref=HEAD:<name>` hint so `git clone` knows its default
-/// branch) if resolved, then every other ref.
-fn advertisement_lines(
- ad: &git_protocol::RefAdvertisement,
- receive: bool,
- push_cert_nonce: Option<&str>,
-) -> Vec<u8> {
- let mut caps = if receive {
- "report-status delete-refs ofs-delta agent=git-ents/1.0".to_owned()
- } else {
- "ofs-delta agent=git-ents/1.0".to_owned()
- };
- if let Some(nonce) = push_cert_nonce {
- caps = format!("{caps} push-cert={nonce}");
- }
- if let Some(head) = &ad.head {
- caps = format!("{caps} symref=HEAD:{head}");
- }
-
- let mut out = Vec::new();
- if ad.refs.is_empty() {
- let null = ObjectId::null(gix_hash::Kind::Sha1);
- out.extend(pkt_line(
- format!("{null} capabilities^{{}}\0{caps}\n").as_bytes(),
- ));
- out.extend_from_slice(FLUSH_PKT);
- return out;
- }
-
- let head_oid = ad
- .head
- .as_ref()
- .and_then(|name| ad.refs.iter().find(|(n, _)| n == name))
- .map(|(_, oid)| *oid);
- let mut first = true;
- if let Some(oid) = head_oid {
- out.extend(pkt_line(format!("{oid} HEAD\0{caps}\n").as_bytes()));
- first = false;
- }
- for (name, oid) in &ad.refs {
- let line = if first {
- first = false;
- format!("{oid} {name}\0{caps}\n")
- } else {
- format!("{oid} {name}\n")
- };
- out.extend(pkt_line(line.as_bytes()));
- }
- out.extend_from_slice(FLUSH_PKT);
- out
-}
crates/git-ents-server/src/render.rs
@@ -1,58 +1,0 @@
-//! MIME-keyed document rendering: the one place that decides, from a
-//! document's declared or inferred MIME type, which of [`asciidoc`] or
-//! [`markdown`]'s converters turns it into HTML (for the web UI) or plain
-//! text (for the CLI). A lookup table rather than a trait hierarchy, the
-//! same style as `registry::RECIPES` in the `git-ents` CLI — MIME is an open
-//! namespace, so unrecognized types fall through to a passthrough instead of
-//! refusing to render at all.
-
-use crate::{asciidoc, markdown};
-
-/// The MIME type this crate treats prose documents as when nothing else
-/// declares one — e.g. a comment or issue body, which carries no filename to
-/// infer an extension from.
-pub const DEFAULT_PROSE_MIME: &str = "text/asciidoc";
-
-/// Guess a document's MIME type from its filename's extension. Replaces
-/// separate `is_asciidoc`/`is_markdown` extension checks with one lookup
-/// that both HTML and text rendering key off of.
-pub fn mime_for_name(name: &str) -> &'static str {
- if asciidoc::is_asciidoc(name) {
- "text/asciidoc"
- } else if markdown::is_markdown(name) {
- "text/markdown"
- } else {
- "text/plain"
- }
-}
-
-/// Render `source` (declared or inferred as `mime`) to an embedded HTML
-/// fragment. Unrecognized MIME types fall through to an escaped `<pre>`
-/// block rather than an error.
-///
-/// ## Requirements
-///
-/// @relation(web.render-registry)
-pub fn to_html(mime: &str, source: &str) -> String {
- match mime {
- "text/asciidoc" => asciidoc::to_html(source),
- "text/markdown" => Some(markdown::to_html(source)),
- _ => None,
- }
- .unwrap_or_else(|| maud::html! { pre { (source) } }.into_string())
-}
-
-/// Render `source` (declared or inferred as `mime`) to plain text, for
-/// terminal output. Unrecognized MIME types fall through to `source`
-/// verbatim.
-///
-/// ## Requirements
-///
-/// @relation(web.render-registry)
-pub fn to_text(mime: &str, source: &str) -> String {
- match mime {
- "text/asciidoc" => asciidoc::to_text(source),
- _ => None,
- }
- .unwrap_or_else(|| source.to_owned())
-}
crates/git-ents-server/src/web/assets.rs
@@ -1,17 +1,0 @@
-//! Static front-end assets, bundled into the binary at compile time so the UI
-//! has no runtime file or framework dependencies.
-
-/// Web fonts, matching the typography of <https://jdc.pub>.
-pub(super) const FONTS: &str = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Lora:wght@500;600;700&display=swap";
-
-/// Hand-written stylesheet (no external CSS framework) so the look stays stable
-/// and self-contained. Colors, type, and radii track <https://jdc.pub>, with a
-/// `prefers-color-scheme` block for automatic dark mode.
-pub(super) const STYLE: &str = include_str!("style.css");
-
-/// Clipboard handler for the clone-URL copy button.
-pub(super) const COPY_SCRIPT: &str = include_str!("copy.js");
-
-/// Polls a running check's live-output fragment and swaps it in, reloading the
-/// page once the server reports the check has finished.
-pub(super) const LIVE_SCRIPT: &str = include_str!("live.js");
crates/git-ents-server/src/web/component.rs
@@ -1,67 +1,0 @@
-//! The generic card every list-shaped meta-ref component renders with: load
-//! its items off the async runtime, then a header (title, count badge),
-//! an error row, an empty-state message, or one [`Render`]ed row per item.
-//!
-//! Not every component's page fits this shape — Issues filters to open-only
-//! and shows dual open/closed counts in place of a single badge, chrome this
-//! card does not have a hook for — so [`super::pages::issues_page`] implements
-//! only [`Loadable`] and reuses [`load`], keeping its own header and body via
-//! each issue's `Render` impl. Forcing that case through [`card`] would mean
-//! adding a header override parameter used by exactly one component, which
-//! the component plan's own trait-bloat rule rules out; splitting the sync
-//! loader from the card chrome into two traits is the same rule applied the
-//! other way, so Issue is not forced to implement a `TITLE`/`empty` it would
-//! never use.
-
-use std::path::Path;
-
-use git_store::component::Component;
-use maud::{Markup, html};
-
-use super::render::Render;
-
-/// A meta-ref component whose items load off the async runtime. Sync —
-/// `git_ents_core::*::load`/`list` shell out to git and read the object database
-/// synchronously — so callers wrap it in exactly one [`load`].
-pub(super) trait Loadable: Send + Sized + 'static {
- /// The component's items.
- fn load(repo: &Path) -> Result<Vec<Self>, String>;
-}
-
-/// Load `T`'s items off the async runtime, wrapping [`Loadable::load`] in the
-/// one `spawn_blocking` every component needs.
-pub(super) async fn load<T: Loadable>(repo: &Path) -> Result<Vec<T>, String> {
- let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || T::load(&repo))
- .await
- .map_err(|err| err.to_string())?
-}
-
-/// A [`Loadable`] component whose items also render as a generic [`card`]:
-/// identity metadata and a [`Render`] impl (both from `git_store::component`),
-/// plus a title and what the card shows when there are no items yet.
-pub(super) trait WebComponent: Loadable + Component + Render {
- /// The card title.
- const TITLE: &'static str;
- /// The card body's empty-state message.
- fn empty() -> Markup;
-}
-
-/// The card chrome every list-shaped component shares: a header with
-/// `T::TITLE` and a count badge, then an error row, `T::empty()`, or one
-/// rendered row per item.
-pub(super) fn card<T: WebComponent>(items: &Result<Vec<T>, String>) -> Markup {
- html! {
- div.card {
- div.card-header {
- (T::TITLE)
- @if let Ok(items) = items { span.count { (items.len()) } }
- }
- @match items {
- Err(err) => div.card-row.muted { "Could not read " (T::PLURAL) ": " (err) }
- Ok(items) if items.is_empty() => (T::empty())
- Ok(items) => { @for item in items { (item.render()) } }
- }
- }
- }
-}
crates/git-ents-server/src/web/copy.js
@@ -1,41 +1,0 @@
-function copyText(text) {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- return navigator.clipboard.writeText(text);
- }
- return new Promise((resolve, reject) => {
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
- try {
- const ok = document.execCommand('copy');
- document.body.removeChild(textarea);
- if (ok) {
- resolve();
- } else {
- reject(new Error('execCommand copy failed'));
- }
- } catch (err) {
- document.body.removeChild(textarea);
- reject(err);
- }
- });
-}
-
-document.querySelectorAll('[data-copy]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const label = btn.textContent;
- copyText(btn.dataset.copy)
- .then(() => {
- btn.textContent = 'Copied';
- setTimeout(() => { btn.textContent = label; }, 1200);
- })
- .catch(() => {
- btn.textContent = 'Copy failed';
- setTimeout(() => { btn.textContent = label; }, 1200);
- });
- });
-});
crates/git-ents-server/src/web/debug.rs
@@ -1,201 +1,0 @@
-//! Interactive debug sessions into a repository's checks Sprite.
-//!
-//! A member who can already sign in to the web UI (see [`super::write`]) can
-//! open a read-write shell in the same persistent Sprite a check run used,
-//! brokered over a WebSocket so the member never needs a Fly credential of
-//! their own: the server holds the one `SPRITES_TOKEN` and relays bytes.
-//! Reachable at the reserved top-level path `/_debug/<repo-path>` — a repo
-//! literally named `_debug` is shadowed, the same tradeoff `/login` already
-//! makes against a repo named `login`.
-
-use std::io::{Read as _, Write as _};
-
-use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
-use axum::extract::{Path, State};
-use axum::http::{HeaderMap, StatusCode};
-use axum::response::{IntoResponse, Response};
-use portable_pty::{CommandBuilder, PtySize, native_pty_system};
-
-use crate::AppState;
-
-/// The pty's initial size, before the CLI's first resize control frame
-/// arrives — the CLI sends one immediately on connecting, so this only
-/// matters for the handful of frames in between.
-const INITIAL_SIZE: PtySize = PtySize {
- rows: 24,
- cols: 80,
- pixel_width: 0,
- pixel_height: 0,
-};
-
-/// Upgrade an authenticated member's request into an interactive shell in
-/// `repo_path`'s checks Sprite.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-pub(crate) async fn handshake(
- State(state): State<AppState>,
- Path(repo_path): Path<String>,
- headers: HeaderMap,
- ws: WebSocketUpgrade,
-) -> Response {
- let segments: Vec<&str> = repo_path.split('/').filter(|s| !s.is_empty()).collect();
- let Some((repo, _rel, rest)) = super::resolve_repo(&state.data_dir, &segments) else {
- return (StatusCode::NOT_FOUND, "no such repository").into_response();
- };
- if !rest.is_empty() {
- return (StatusCode::NOT_FOUND, "no such repository").into_response();
- }
-
- let cookie = headers
- .get(axum::http::header::COOKIE)
- .and_then(|value| value.to_str().ok());
- let Some(session) = super::write::snapshot(&state.sessions, cookie) else {
- return (StatusCode::UNAUTHORIZED, "sign in to open a debug session").into_response();
- };
- let store = match git_store::Store::open(&repo) {
- Ok(store) => store,
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("cannot open store: {e}"),
- )
- .into_response();
- }
- };
- if super::write::member_for_public_key_with(&store, &session.public_key).is_none() {
- return (
- StatusCode::FORBIDDEN,
- "your web key is not a member of this repository",
- )
- .into_response();
- }
-
- let sprite = git_effect::engine::sprite_name(&repo);
- let ready = tokio::task::spawn_blocking({
- let sprite = sprite.clone();
- move || {
- git_effect::engine::ensure_auth()
- .and_then(|()| git_effect::engine::ensure_sprite(&sprite))
- }
- })
- .await;
- if !matches!(ready, Ok(Ok(()))) {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- "could not prepare the sprite",
- )
- .into_response();
- }
-
- ws.on_upgrade(move |socket| relay(socket, sprite))
-}
-
-/// Spawn an interactive shell in `sprite` and relay it over `socket` until
-/// either side closes: the Sprite CLI's own `--tty` handles the remote
-/// pseudo-TTY, but the broker allocates its *own* local pty for the `sprite
-/// exec --tty` process so a resize control frame (see below) has something to
-/// apply to — plain pipes have no window size to change.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-async fn relay(mut socket: WebSocket, sprite: String) {
- let pair = match native_pty_system().openpty(INITIAL_SIZE) {
- Ok(pair) => pair,
- Err(_could_not_allocate) => return,
- };
- let mut cmd = CommandBuilder::new("sprite");
- cmd.args(["exec", "--tty", "-s", &sprite, "--", "/bin/bash"]);
- let mut child = match pair.slave.spawn_command(cmd) {
- Ok(child) => child,
- Err(_could_not_spawn) => return,
- };
- // Drop our copy of the slave side once the child holds it, so the
- // master's reader sees EOF when the child actually exits rather than
- // when this process happens to close it.
- drop(pair.slave);
-
- let master = pair.master;
- let (Ok(reader), Ok(mut writer)) = (master.try_clone_reader(), master.take_writer()) else {
- return;
- };
-
- // The pty's Read/Write are blocking, so each direction gets its own
- // thread; the read side hands chunks to the async loop over a channel,
- // the write side is fed the same way so a slow write never blocks the
- // select loop.
- let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
- std::thread::spawn(move || {
- let mut reader = reader;
- let mut buf = [0u8; 4096];
- loop {
- match reader.read(&mut buf) {
- Ok(0) | Err(_) => break,
- Ok(n) => {
- let Some(chunk) = buf.get(..n) else { break };
- if out_tx.send(chunk.to_vec()).is_err() {
- break;
- }
- }
- }
- }
- });
- let (in_tx, in_rx) = std::sync::mpsc::channel::<Vec<u8>>();
- std::thread::spawn(move || {
- while let Ok(data) = in_rx.recv() {
- if writer.write_all(&data).is_err() {
- break;
- }
- }
- });
-
- loop {
- tokio::select! {
- chunk = out_rx.recv() => {
- match chunk {
- Some(data) => {
- if socket.send(Message::Binary(data.into())).await.is_err() {
- break;
- }
- }
- None => break,
- }
- }
- frame = socket.recv() => {
- match frame {
- Some(Ok(Message::Binary(data))) => {
- if in_tx.send(data.to_vec()).is_err() {
- break;
- }
- }
- Some(Ok(Message::Text(text))) => {
- if let Some(size) = parse_resize(&text) {
- let _resized = master.resize(size);
- }
- }
- Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
- _ => {}
- }
- }
- }
- }
- let _killed = child.kill();
-}
-
-/// Parse a resize control frame, `"<cols> <rows>"`, as sent by the CLI on
-/// connect and on every local `SIGWINCH`.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-fn parse_resize(text: &str) -> Option<PtySize> {
- let (cols, rows) = text.split_once(' ')?;
- Some(PtySize {
- cols: cols.parse().ok()?,
- rows: rows.parse().ok()?,
- pixel_width: 0,
- pixel_height: 0,
- })
-}
crates/git-ents-server/src/web/git.rs
@@ -1,388 +1,0 @@
-//! The data layer behind the web UI: thin wrappers over `git` plus the parsing
-//! that turns its output into the trees, releases, and language breakdowns the
-//! views render.
-
-use std::path::Path;
-use std::process::Stdio;
-
-use gix_date::Time;
-use gix_hash::ObjectId;
-use gix_object::tree::{Entry, EntryKind, EntryMode};
-use tokio::io::AsyncReadExt as _;
-use tokio::process::Command;
-
-use crate::http::{MAX_REPO_DEPTH, is_bare_repo};
-
-/// Run `git -C <repo> <args>` and return its stdout as lossy UTF-8, or `None` on
-/// failure.
-pub(super) async fn git_output(repo: &Path, args: &[&str]) -> Option<String> {
- git_output_bytes(repo, args)
- .await
- .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
-}
-
-/// Run `git -C <repo> <args>` and return its raw stdout bytes, or `None` on
-/// failure. Used for blob contents, which may not be valid UTF-8.
-pub(super) async fn git_output_bytes(repo: &Path, args: &[&str]) -> Option<Vec<u8>> {
- let out = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(args)
- .stderr(Stdio::null())
- .output()
- .await
- .ok()?;
- if !out.status.success() {
- return None;
- }
- Some(out.stdout)
-}
-
-/// Run `git -C <repo> <args>` capturing at most `cap` bytes of stdout, returning
-/// the captured bytes and whether stdout exceeded `cap`. `None` on a spawn
-/// failure or, for output that fit under the cap, a non-zero exit.
-///
-/// Reading at most `cap + 1` bytes and killing git once the cap is reached
-/// bounds the memory a single request can consume, so an arbitrarily large blob
-/// or diff renders as a truncation notice instead of being slurped whole — the
-/// difference between a capped response and an out-of-memory kill.
-///
-/// ## Requirements
-///
-/// @relation(nonfunctional.memory-cap)
-pub(super) async fn git_output_capped(
- repo: &Path,
- args: &[&str],
- cap: usize,
-) -> Option<(Vec<u8>, bool)> {
- let mut child = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(args)
- .stdout(Stdio::piped())
- .stderr(Stdio::null())
- .spawn()
- .ok()?;
- let mut stdout = child.stdout.take()?;
- let mut buf = Vec::new();
- let probe = u64::try_from(cap).unwrap_or(u64::MAX).saturating_add(1);
- (&mut stdout).take(probe).read_to_end(&mut buf).await.ok()?;
- if buf.len() > cap {
- // Over the cap: keep what we have, stop git, and report truncation.
- buf.truncate(cap);
- let _killed = child.start_kill();
- let _reaped = child.wait().await;
- return Some((buf, true));
- }
- let status = child.wait().await.ok()?;
- if status.success() {
- Some((buf, false))
- } else {
- None
- }
-}
-
-/// The entries of the root tree at `HEAD`, directories first then by name.
-pub(super) async fn root_tree(repo: &Path, has_head: bool) -> Vec<Entry> {
- if !has_head {
- return Vec::new();
- }
- list_tree(repo, "HEAD").await
-}
-
-/// The entries of the tree named by `spec` (a git tree-ish such as `HEAD` or
-/// `HEAD:src`), directories first then by name. Empty if `spec` is not a tree.
-pub(super) async fn list_tree(repo: &Path, spec: &str) -> Vec<Entry> {
- let Some(out) = git_output(repo, &["ls-tree", spec]).await else {
- return Vec::new();
- };
- let mut entries: Vec<Entry> = out.lines().filter_map(parse_tree_entry).collect();
- entries.sort_by(|a, b| {
- b.mode
- .is_tree()
- .cmp(&a.mode.is_tree())
- .then_with(|| a.filename.cmp(&b.filename))
- });
- entries
-}
-
-/// Parse one `git ls-tree` line (`<mode> <type> <oid>\t<name>`) into a tree
-/// entry, or `None` when it is malformed.
-fn parse_tree_entry(line: &str) -> Option<Entry> {
- let (meta, name) = line.split_once('\t')?;
- let mut cols = meta.split(' ');
- let mode = cols.next()?;
- let oid = cols.nth(1)?;
- Some(Entry {
- mode: entry_mode(mode),
- filename: name.into(),
- oid: ObjectId::from_hex(oid.as_bytes()).ok()?,
- })
-}
-
-/// Map a `git ls-tree` mode column to a tree entry mode.
-fn entry_mode(mode: &str) -> EntryMode {
- match mode {
- "040000" | "40000" => EntryKind::Tree,
- "120000" => EntryKind::Link,
- "160000" => EntryKind::Commit,
- "100755" => EntryKind::BlobExecutable,
- _ => EntryKind::Blob,
- }
- .into()
-}
-
-/// Join the path segments of a browse view, rejecting empty or traversing
-/// components. The result is used only as a git tree path (`HEAD:<path>`), never
-/// touched on disk, but refusing `..` keeps the rendered links well-formed.
-pub(super) fn browse_path(sub: &[&str]) -> Option<String> {
- if sub.iter().any(|s| s.is_empty() || *s == "." || *s == "..") {
- return None;
- }
- Some(sub.join("/"))
-}
-
-/// All bare repositories under `root`, as relative slash paths, sorted.
-pub(super) fn discover_repos(root: &Path) -> Vec<String> {
- let mut repos = Vec::new();
- collect_repos(root, root, MAX_REPO_DEPTH, &mut repos);
- repos.sort();
- repos
-}
-
-/// Recurse into `dir` (up to `depth` levels) collecting bare repositories.
-fn collect_repos(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
- if depth == 0 {
- return;
- }
- let Ok(entries) = std::fs::read_dir(dir) else {
- return;
- };
- for entry in entries.flatten() {
- let path = entry.path();
- if !path.is_dir() {
- continue;
- }
- if is_bare_repo(&path) {
- if let Ok(rel) = path.strip_prefix(root) {
- out.push(rel.to_string_lossy().replace('\\', "/"));
- }
- } else {
- collect_repos(root, &path, depth.saturating_sub(1), out);
- }
- }
-}
-
-/// A language's display name, swatch color (a CSS custom property), and the
-/// percentage of tracked bytes it accounts for.
-pub(super) type Language = (&'static str, &'static str, u8);
-
-/// Map a filename to a language name and swatch color by its extension, or
-/// `None` for files that do not count toward the language breakdown.
-fn classify_language(name: &str) -> Option<(&'static str, &'static str)> {
- let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
- let lang = match ext.as_str() {
- "rs" => ("Rust", "var(--s-type)"),
- "html" | "htm" => ("HTML", "var(--s-func)"),
- "css" => ("CSS", "var(--s-prop)"),
- "js" | "mjs" | "cjs" => ("JavaScript", "var(--s-const)"),
- "ts" | "tsx" => ("TypeScript", "var(--s-prop)"),
- "py" => ("Python", "var(--s-string)"),
- "go" => ("Go", "var(--s-prop)"),
- "c" | "h" => ("C", "var(--s-const)"),
- "cpp" | "cc" | "hpp" | "cxx" => ("C++", "var(--s-const)"),
- "sh" | "bash" => ("Shell", "var(--s-func)"),
- "toml" => ("TOML", "var(--s-type)"),
- "yaml" | "yml" => ("YAML", "var(--s-prop)"),
- "json" => ("JSON", "var(--s-const)"),
- "md" | "adoc" | "asciidoc" => ("Prose", "var(--s-comment)"),
- _ => return None,
- };
- Some(lang)
-}
-
-/// The language breakdown for `HEAD`, by tracked blob size, as the top few
-/// languages with integer percentages summing to roughly 100.
-pub(super) async fn languages(repo: &Path) -> Vec<Language> {
- let Some(out) = git_output(repo, &["ls-tree", "-r", "-l", "HEAD"]).await else {
- return Vec::new();
- };
- let mut totals: Vec<(&'static str, &'static str, u64)> = Vec::new();
- let mut grand: u64 = 0;
- for line in out.lines() {
- let Some((meta, name)) = line.split_once('\t') else {
- continue;
- };
- let size: u64 = meta
- .split_whitespace()
- .nth(3)
- .and_then(|s| s.parse().ok())
- .unwrap_or(0);
- let Some((lang, color)) = classify_language(name) else {
- continue;
- };
- grand = grand.saturating_add(size);
- match totals.iter_mut().find(|(l, _, _)| *l == lang) {
- Some(entry) => entry.2 = entry.2.saturating_add(size),
- None => totals.push((lang, color, size)),
- }
- }
- if grand == 0 {
- return Vec::new();
- }
- totals.sort_by_key(|b| std::cmp::Reverse(b.2));
- totals.truncate(4);
- totals
- .into_iter()
- .map(|(lang, color, bytes)| {
- let pct = bytes.saturating_mul(100).checked_div(grand).unwrap_or(0);
- (lang, color, u8::try_from(pct).unwrap_or(100))
- })
- .filter(|(_, _, pct)| *pct > 0)
- .collect()
-}
-
-/// A tagged release: its tag, the release name and notes drawn from the tag (or
-/// commit) message, the target commit's date, and that commit's id.
-pub(super) struct Release {
- pub(super) tag: String,
- pub(super) title: String,
- pub(super) body: String,
- pub(super) date: Time,
- pub(super) oid: ObjectId,
-}
-
-/// Parse a strict-ISO 8601 git date (`%aI`) into a gitoxide time.
-pub(super) fn parse_iso(input: &str) -> Option<Time> {
- gix_date::parse(input, None).ok()
-}
-
-/// All tags as releases, newest first by creation date.
-pub(super) async fn releases(repo: &Path) -> Vec<Release> {
- let Some(list) = git_output(repo, &["tag", "--sort=-creatordate", "--list"]).await else {
- return Vec::new();
- };
- let mut out = Vec::new();
- for tag in list
- .lines()
- .map(str::trim)
- .filter(|t| !t.is_empty())
- .take(40)
- {
- let Some(meta) = git_output(repo, &["log", "-1", "--format=%H%x00%aI", tag]).await else {
- continue;
- };
- let mut parts = meta.trim().split('\u{0}');
- let Some(oid) = parts
- .next()
- .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
- else {
- continue;
- };
- let Some(date) = parts.next().and_then(parse_iso) else {
- continue;
- };
- let notes = git_output(
- repo,
- &[
- "tag",
- "--list",
- "--format=%(contents:subject)%00%(contents:body)",
- tag,
- ],
- )
- .await
- .unwrap_or_default();
- let mut np = notes.split('\u{0}');
- let title = np.next().unwrap_or_default().trim().to_owned();
- let body = np.next().unwrap_or_default().trim().to_owned();
- out.push(Release {
- tag: tag.to_owned(),
- title,
- body,
- date,
- oid,
- });
- }
- out
-}
-
-/// The newest release, if any.
-pub(super) async fn latest_release(repo: &Path) -> Option<Release> {
- releases(repo).await.into_iter().next()
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, reason = "unit test")]
-
- use std::process::Command as SyncCommand;
-
- use super::*;
-
- fn commit_blob(repo: &Path, name: &str, bytes: usize) {
- SyncCommand::new("git")
- .arg("-C")
- .arg(repo)
- .args(["init", "-q"])
- .status()
- .unwrap();
- std::fs::write(repo.join(name), vec![b'x'; bytes]).unwrap();
- for args in [
- vec!["add", "."],
- vec![
- "-c",
- "user.name=t",
- "-c",
- "user.email=t@e",
- "commit",
- "-qm",
- "x",
- ],
- ] {
- SyncCommand::new("git")
- .arg("-C")
- .arg(repo)
- .args(&args)
- .status()
- .unwrap();
- }
- }
-
- // @relation(nonfunctional.memory-cap, role=Verifies)
- #[tokio::test]
- async fn capped_read_flags_oversized_output() {
- let dir = tempfile::tempdir().unwrap();
- commit_blob(dir.path(), "big.txt", 4096);
- let (bytes, truncated) =
- git_output_capped(dir.path(), &["cat-file", "-p", "HEAD:big.txt"], 1024)
- .await
- .unwrap();
- assert!(truncated);
- assert_eq!(bytes.len(), 1024);
- }
-
- // @relation(nonfunctional.memory-cap, role=Verifies)
- #[tokio::test]
- async fn capped_read_returns_full_small_output() {
- let dir = tempfile::tempdir().unwrap();
- commit_blob(dir.path(), "small.txt", 100);
- let (bytes, truncated) =
- git_output_capped(dir.path(), &["cat-file", "-p", "HEAD:small.txt"], 1024)
- .await
- .unwrap();
- assert!(!truncated);
- assert_eq!(bytes.len(), 100);
- }
-
- #[tokio::test]
- async fn capped_read_reports_failure_as_none() {
- let dir = tempfile::tempdir().unwrap();
- commit_blob(dir.path(), "small.txt", 10);
- assert!(
- git_output_capped(dir.path(), &["cat-file", "-p", "HEAD:missing"], 1024)
- .await
- .is_none()
- );
- }
-}
crates/git-ents-server/src/web/icons.rs
@@ -1,68 +1,0 @@
-//! Inline icons, vendored from [Octicons](https://primer.style/octicons/) (MIT;
-//! see `icons/LICENSE`). The upstream `.svg` files are bundled verbatim at
-//! compile time and given the page's own `.icon` class as they are emitted, so
-//! the UI carries no runtime asset dependency and the icon artwork has a clear,
-//! auditable provenance.
-
-use std::sync::LazyLock;
-
-use maud::{Markup, PreEscaped};
-
-/// Adapt an upstream Octicon to this UI: tag it with the `.icon` class the
-/// stylesheet targets and mark it decorative for assistive tech. Every vendored
-/// file opens with a bare `<svg …>` element, so a single prefix swap suffices.
-fn inline(svg: &str) -> String {
- svg.replacen("<svg ", "<svg class=\"icon\" aria-hidden=\"true\" ", 1)
-}
-
-/// Define an icon accessor per vendored Octicon file. Each prepares its inline
-/// markup once and hands out a cheap clone on use.
-macro_rules! icons {
- ($($name:ident => $file:literal),* $(,)?) => {
- $(
- pub(super) fn $name() -> Markup {
- static HTML: LazyLock<String> =
- LazyLock::new(|| inline(include_str!(concat!("icons/", $file, ".svg"))));
- PreEscaped(HTML.clone())
- }
- )*
- };
-}
-
-icons! {
- icon_repo => "repo",
- icon_folder => "file-directory-fill",
- icon_file => "file",
- icon_plus => "plus",
- icon_issue => "issue-opened",
- icon_check => "check",
- icon_chevron => "chevron-right",
- icon_branch => "git-branch",
- icon_tag => "tag",
- icon_clock => "clock",
- icon_commit => "git-commit",
- icon_logo => "north-star",
- icon_search => "search",
-}
-
-/// Zero-size icon bundle so Askama templates can emit an icon as
-/// `{{ icons.icon_search()|safe }}` — the same inline SVG the free functions
-/// hand to Maud, as a raw HTML string (Askama's `safe` filter needs `Display`,
-/// which Maud's `PreEscaped` does not implement). Methods are added here as
-/// tabs migrate off Maud.
-pub(super) struct Icons;
-
-impl Icons {
- pub(super) fn icon_plus(&self) -> String {
- icon_plus().into_string()
- }
- pub(super) fn icon_issue(&self) -> String {
- icon_issue().into_string()
- }
- pub(super) fn icon_check(&self) -> String {
- icon_check().into_string()
- }
- pub(super) fn icon_search(&self) -> String {
- icon_search().into_string()
- }
-}
crates/git-ents-server/src/web/icons/LICENSE
@@ -1,21 +1,0 @@
-MIT License
-
-Copyright (c) 2026 GitHub Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
crates/git-ents-server/src/web/icons/check.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>
crates/git-ents-server/src/web/icons/chevron-right.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
crates/git-ents-server/src/web/icons/clock.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>
crates/git-ents-server/src/web/icons/file-directory-fill.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>
crates/git-ents-server/src/web/icons/file.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
crates/git-ents-server/src/web/icons/git-branch.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>
crates/git-ents-server/src/web/icons/git-commit.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>
crates/git-ents-server/src/web/icons/issue-opened.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/></svg>
crates/git-ents-server/src/web/icons/north-star.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.5.75a.75.75 0 0 0-1.5 0v5.19L4.391 3.33a.75.75 0 1 0-1.06 1.061L5.939 7H.75a.75.75 0 0 0 0 1.5h5.19l-2.61 2.609a.75.75 0 1 0 1.061 1.06L7 9.561v5.189a.75.75 0 0 0 1.5 0V9.56l2.609 2.61a.75.75 0 1 0 1.06-1.061L9.561 8.5h5.189a.75.75 0 0 0 0-1.5H9.56l2.61-2.609a.75.75 0 0 0-1.061-1.06L8.5 5.939V.75Z"/></svg>
crates/git-ents-server/src/web/icons/plus.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
crates/git-ents-server/src/web/icons/repo.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/></svg>
crates/git-ents-server/src/web/icons/search.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
crates/git-ents-server/src/web/icons/tag.svg
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></svg>
crates/git-ents-server/src/web/live.js
@@ -1,33 +1,0 @@
-document.querySelectorAll('[data-live-check]').forEach((container) => {
- const url = container.dataset.liveCheck;
- let lastHtml = container.innerHTML;
- const poll = () => {
- fetch(url, { cache: 'no-store' })
- .then((response) => {
- const state = response.headers.get('X-Check-Live');
- return response.text().then((html) => ({ state, html }));
- })
- .then(({ state, html }) => {
- if (state === 'done') {
- window.location.reload();
- return;
- }
- if (state === 'stale') {
- container.insertAdjacentHTML(
- 'beforeend',
- '<p class="shell-note">No live output available right now; this check is still marked in progress.</p>'
- );
- return;
- }
- const isPlaceholder = html.includes('terminal-empty');
- const hadRealOutput = !lastHtml.includes('terminal-empty');
- if (html !== lastHtml && !(isPlaceholder && hadRealOutput)) {
- lastHtml = html;
- container.innerHTML = html;
- }
- setTimeout(poll, 1000);
- })
- .catch(() => setTimeout(poll, 2000));
- };
- poll();
-});
crates/git-ents-server/src/web/mod.rs
@@ -1,927 +1,0 @@
-//! Browser-facing HTML: a small, hand-styled web UI rendered server-side with
-//! Maud. The look mirrors <https://jdc.pub>: DM Sans / Lora / IBM Plex Mono on a
-//! warm-gold palette that follows the system light/dark preference. The git
-//! smart-HTTP gateway in [`crate::http`] delegates plain browser GETs here.
-//!
-//! The module is split by concern: [`assets`] bundles the CSS/JS, [`icons`]
-//! holds the inline SVGs, [`git`] is the data layer over `git`, [`render`]
-//! turns reflected meta-ref values into HTML, and [`pages`] renders each tab.
-//! This file owns routing and the shared page shell.
-
-mod assets;
-mod component;
-mod debug;
-mod git;
-mod icons;
-mod pages;
-mod render;
-mod write;
-
-use std::path::{Path, PathBuf};
-
-use axum::body::Bytes;
-use axum::http::header::{LOCATION, SET_COOKIE};
-use axum::http::{HeaderMap, StatusCode};
-use axum::response::{IntoResponse, Response};
-use maud::{DOCTYPE, Markup, PreEscaped, html};
-
-use crate::AppState;
-use crate::http::{MAX_REPO_DEPTH, is_bare_repo, valid_segment};
-
-pub(crate) use self::debug::handshake;
-pub(crate) use self::write::{Challenges, Sessions, new_challenges, new_sessions};
-
-/// Who is signed in for the current request, resolved per repository: a member's
-/// web key authorizes edits only on a repo whose member list contains it.
-pub(super) struct Auth {
- /// The session key's display label.
- label: String,
- /// The member username this key maps to in the current repo, when it is a
- /// member there — the gate for showing edit controls.
- username: Option<String>,
- /// The session's CSRF token, echoed in edit forms.
- csrf: String,
-}
-
-use self::assets::{COPY_SCRIPT, FONTS, LIVE_SCRIPT, STYLE};
-use self::git::{discover_repos, git_output};
-use self::icons::{icon_branch, icon_chevron, icon_folder, icon_logo, icon_repo, icon_search};
-
-/// Render the page for `path`: the repository index at the root, a repository
-/// overview, or one of its browse views (`tree`, `blob`, `commit`). `host` is
-/// the request's `Host` header, used to build a copy-pasteable clone URL.
-///
-/// ## Requirements
-///
-/// @relation(web.server-rendered, web.auth.challenge)
-pub(crate) async fn render(
- state: &AppState,
- path: &str,
- query: Option<&str>,
- host: Option<&str>,
- cookie: Option<&str>,
- referer: Option<&str>,
-) -> Response {
- let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
- let session = write::snapshot(&state.sessions, cookie);
- if segments.is_empty() {
- return index(state, session.as_ref()).into_response();
- }
- if segments == ["login"] {
- let challenge = match session {
- Some(_) => None,
- None => write::issue_challenge(&state.challenges).ok(),
- };
- let next = referer_path(referer);
- let signed_out = query_flag(query, "signed_out");
- return login_page(
- session.as_ref(),
- challenge.as_deref(),
- None,
- next.as_deref(),
- signed_out,
- )
- .into_response();
- }
- // The CLI signs in the same way the browser form does, just without the
- // HTML: a bare nonce to sign, and (via `handle_post`) a bare token back.
- if segments == ["login", "cli"] {
- return match write::issue_challenge(&state.challenges) {
- Ok(nonce) => nonce.into_response(),
- Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
- };
- }
-
- if let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) {
- return route(state, &repo, &rel, rest, query, host, session).await;
- }
-
- not_found("No such repository.").into_response()
-}
-
-/// Resolve the leading path segments to a repository: the shortest valid prefix
-/// (up to [`MAX_REPO_DEPTH`] segments) that names a bare repo on disk, with the
-/// rest of the path selecting a view. Resolving the boundary this way keeps a
-/// repo named `tree`/`blob`/`commit` distinct from the route markers of the same
-/// name.
-fn resolve_repo<'a>(
- data_dir: &Path,
- segments: &'a [&'a str],
-) -> Option<(PathBuf, String, &'a [&'a str])> {
- let depth_limit = segments.len().min(MAX_REPO_DEPTH);
- for depth in 1..=depth_limit {
- let repo_segs = segments.get(..depth)?;
- if !repo_segs.iter().all(|s| valid_segment(s)) {
- break;
- }
- let relative: PathBuf = repo_segs.iter().collect();
- let repo = data_dir.join(&relative);
- // Bare repos are stored on disk as `<name>.git`, matching what
- // `git http-backend` expects, but the friendly web/CLI paths never
- // include the suffix — so try it on the last segment before giving up.
- let repo = if is_bare_repo(&repo) {
- repo
- } else if let Some((last, init)) = repo_segs.split_last() {
- let mut with_suffix = data_dir.join(init.iter().collect::<PathBuf>());
- with_suffix.push(format!("{last}.git"));
- if is_bare_repo(&with_suffix) {
- with_suffix
- } else {
- continue;
- }
- } else {
- continue;
- };
- let rel = repo_segs.join("/");
- let rest = segments.get(depth..).unwrap_or_default();
- return Some((repo, rel, rest));
- }
- None
-}
-
-/// Handle a browser POST: signing in, signing out, or saving a settings edit.
-/// Git wire POSTs never reach here — [`crate::http`] routes those to the backend.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge, web.auth.session)
-pub(crate) async fn handle_post(
- state: &AppState,
- path: &str,
- headers: &HeaderMap,
- body: Bytes,
-) -> Response {
- let cookie = headers
- .get(axum::http::header::COOKIE)
- .and_then(|value| value.to_str().ok());
- let secure = is_secure_request(headers);
- let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
-
- if segments == ["login"] {
- let next = write::field(&body, "next");
- return match write::login(&state.sessions, &state.challenges, &body) {
- Ok(token) => redirect(
- safe_redirect_target(next.as_deref()),
- Some(session_cookie(&token, secure)),
- ),
- Err(error) => {
- let challenge = write::issue_challenge(&state.challenges).ok();
- login_page(
- None,
- challenge.as_deref(),
- Some(&error),
- next.as_deref(),
- false,
- )
- .into_response()
- }
- };
- }
- if segments == ["login", "cli"] {
- return match write::login(&state.sessions, &state.challenges, &body) {
- Ok(token) => token.into_response(),
- Err(error) => (StatusCode::UNAUTHORIZED, error).into_response(),
- };
- }
- if segments == ["logout"] {
- // A cross-site form cannot read the session's CSRF token, so an absent
- // or wrong one means the request did not originate from our own page.
- if !write::csrf_ok(
- &state.sessions,
- cookie,
- &write::field(&body, "csrf").unwrap_or_default(),
- ) {
- return redirect("/login", None);
- }
- write::logout(&state.sessions, cookie);
- return redirect("/login?signed_out=1", Some(cleared_cookie(secure)));
- }
-
- let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) else {
- return not_found("No such repository.").into_response();
- };
- match rest {
- ["settings"] => save_settings(state, &repo, &rel, cookie, body).await,
- ["comment"] => save_comment(state, &repo, &rel, cookie, body).await,
- _ => not_found("No such page.").into_response(),
- }
-}
-
-/// Whether this server can actually land browser edits: it needs the signed-push
-/// gate (nonce seed + hooks) and its own signing key, all of which
-/// [`write::edit_config`] requires. When any is unset, edit controls are not
-/// offered so a member is never told they can edit when a submit would only fail.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.edit)
-fn editing_enabled(state: &AppState) -> bool {
- state.cert_nonce_seed.is_some() && state.hooks_dir.is_some() && state.web_signing_key.is_some()
-}
-
-/// Whether the request reached us over HTTPS — directly, or through a TLS
-/// terminator that set `X-Forwarded-Proto`. Gates the cookie `Secure` flag so a
-/// plain-HTTP development server still works.
-fn is_secure_request(headers: &HeaderMap) -> bool {
- headers
- .get("X-Forwarded-Proto")
- .and_then(|value| value.to_str().ok())
- .is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
-}
-
-/// Apply a settings edit, then redirect back to the settings page on success or
-/// render the reason it was rejected.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.edit)
-async fn save_settings(
- state: &AppState,
- repo: &Path,
- rel: &str,
- cookie: Option<&str>,
- body: Bytes,
-) -> Response {
- let back = format!("/{rel}/settings");
- let (Some(seed), Some(hooks), Some(signing_key)) = (
- state.cert_nonce_seed.clone(),
- state.hooks_dir.clone(),
- state.web_signing_key.clone(),
- ) else {
- return edit_error(
- &back,
- "Editing is disabled: this server has no web signing key or signed-push gate.",
- )
- .into_response();
- };
- if write::snapshot(&state.sessions, cookie).is_none() {
- return edit_error(&back, "you are not signed in; sign in and try again").into_response();
- }
- if !write::csrf_ok(
- &state.sessions,
- cookie,
- &write::field(&body, "csrf").unwrap_or_default(),
- ) {
- return edit_error(
- &back,
- "the edit could not be verified; reload and try again",
- )
- .into_response();
- }
- let edit = write::ConfigEdit {
- description: write::field(&body, "description").unwrap_or_default(),
- homepage: write::field(&body, "homepage").unwrap_or_default(),
- topics: write::field(&body, "topics")
- .unwrap_or_default()
- .split(',')
- .map(str::trim)
- .filter(|topic| !topic.is_empty())
- .map(str::to_owned)
- .collect(),
- };
-
- let sessions = state.sessions.clone();
- let cookie = cookie.map(str::to_owned);
- let repo = repo.to_owned();
- let result = tokio::task::spawn_blocking(move || {
- write::edit_config(
- &sessions,
- cookie.as_deref(),
- &repo,
- &edit,
- &seed,
- &hooks,
- &signing_key,
- )
- })
- .await;
-
- match result {
- Ok(Ok(())) => redirect(&format!("{back}?saved=1"), None),
- Ok(Err(error)) => edit_error(&back, &error).into_response(),
- Err(_join) => edit_error(&back, "the edit did not complete").into_response(),
- }
-}
-
-/// Record a code comment posted from a file view, then redirect back to that
-/// file on success or render the reason it was rejected.
-///
-/// ## Requirements
-///
-/// @relation(web.comments)
-async fn save_comment(
- state: &AppState,
- repo: &Path,
- rel: &str,
- cookie: Option<&str>,
- body: Bytes,
-) -> Response {
- let path = write::field(&body, "path").unwrap_or_default();
- let back = format!("/{rel}/blob/{path}");
- let (Some(seed), Some(hooks), Some(signing_key)) = (
- state.cert_nonce_seed.clone(),
- state.hooks_dir.clone(),
- state.web_signing_key.clone(),
- ) else {
- return edit_error(
- &back,
- "Editing is disabled: this server has no web signing key or signed-push gate.",
- )
- .into_response();
- };
- if write::snapshot(&state.sessions, cookie).is_none() {
- return edit_error(&back, "you are not signed in; sign in and try again").into_response();
- }
- if !write::csrf_ok(
- &state.sessions,
- cookie,
- &write::field(&body, "csrf").unwrap_or_default(),
- ) {
- return edit_error(
- &back,
- "the comment could not be verified; reload and try again",
- )
- .into_response();
- }
- let lines = match write::parse_lines(&write::field(&body, "lines").unwrap_or_default()) {
- Ok(lines) => lines,
- Err(error) => return edit_error(&back, &error).into_response(),
- };
- let text = write::field(&body, "body")
- .unwrap_or_default()
- .trim()
- .to_owned();
- if path.is_empty() || text.is_empty() {
- return edit_error(&back, "a comment needs a file path and a body").into_response();
- }
- let edit = write::CommentEdit {
- path,
- lines,
- body: text,
- };
-
- let sessions = state.sessions.clone();
- let cookie = cookie.map(str::to_owned);
- let repo = repo.to_owned();
- let result = tokio::task::spawn_blocking(move || {
- write::add_comment(
- &sessions,
- cookie.as_deref(),
- &repo,
- &edit,
- &seed,
- &hooks,
- &signing_key,
- )
- })
- .await;
-
- match result {
- Ok(Ok(())) => redirect(&format!("{back}?commented=1"), None),
- Ok(Err(error)) => edit_error(&back, &error).into_response(),
- Err(_join) => edit_error(&back, "the comment did not complete").into_response(),
- }
-}
-
-/// The path (with query) a same-origin `Referer` points at, so signing in
-/// returns a visitor to the page they came from rather than always to `/`.
-/// Rejects anything that is not a local path (a cross-origin or
-/// protocol-relative value, or `/login` itself, which would just bounce back).
-fn referer_path(referer: Option<&str>) -> Option<String> {
- let referer = referer?;
- let after_scheme = referer.split_once("://").map_or(referer, |(_, rest)| rest);
- let path = after_scheme.find('/').and_then(|i| after_scheme.get(i..))?;
- (safe_redirect_target(Some(path)) == path).then(|| path.to_owned())
-}
-
-/// A validated redirect target: `path` when it is a local, non-`/login` path,
-/// or `/` otherwise. Guards both the `Referer`-derived value above and the
-/// `next` form field, which a client could otherwise set to anything.
-fn safe_redirect_target(path: Option<&str>) -> &str {
- match path {
- Some(p) if p.starts_with('/') && !p.starts_with("//") && p != "/login" => p,
- _ => "/",
- }
-}
-
-/// Whether `query` (an unparsed `key=value&...` string) carries `key=1` — the
-/// confirmation flag a redirect appends after a write lands, so the page it
-/// sends the visitor back to can show a one-time success notice.
-fn query_flag(query: Option<&str>, key: &str) -> bool {
- query
- .into_iter()
- .flat_map(|q| q.split('&'))
- .any(|pair| pair == format!("{key}=1"))
-}
-
-/// A one-time success banner for a redirect target, e.g. after a settings save
-/// or a comment post lands.
-pub(super) fn notice_banner(message: &str) -> Markup {
- html! { p.notice.notice-success { (message) } }
-}
-
-/// A `303 See Other` redirect to `location`, optionally setting a cookie.
-fn redirect(location: &str, set_cookie: Option<String>) -> Response {
- let mut builder = Response::builder()
- .status(StatusCode::SEE_OTHER)
- .header(LOCATION, location);
- if let Some(cookie) = set_cookie {
- builder = builder.header(SET_COOKIE, cookie);
- }
- builder
- .body(axum::body::Body::empty())
- .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
-}
-
-/// The `Set-Cookie` value that opens a session, marked `Secure` over HTTPS.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.session)
-fn session_cookie(token: &str, secure: bool) -> String {
- format!(
- "{}={token}; Path=/; HttpOnly; SameSite=Lax{}",
- write::COOKIE,
- if secure { "; Secure" } else { "" }
- )
-}
-
-/// The `Set-Cookie` value that clears a session.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.session)
-fn cleared_cookie(secure: bool) -> String {
- format!(
- "{}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}",
- write::COOKIE,
- if secure { "; Secure" } else { "" }
- )
-}
-
-/// Dispatch the part of the path that follows the repository to a browse view.
-/// Each top-level tab is its own route, since the product is server-rendered
-/// with no client JavaScript.
-///
-/// ## Requirements
-///
-/// @relation(web.server-rendered, web.tabs)
-async fn route(
- state: &AppState,
- repo: &Path,
- rel: &str,
- rest: &[&str],
- query: Option<&str>,
- host: Option<&str>,
- session: Option<write::SessionSnapshot>,
-) -> Response {
- let editing = editing_enabled(state);
- let live_runs = &state.live_runs;
- let meta = gather_meta(repo, rel).await;
- let auth = resolve_auth(repo, session).await;
- match rest.split_first() {
- None => pages::repo_page(repo, &meta, host, auth.as_ref())
- .await
- .into_response(),
- Some((&"files", sub)) => pages::files_page(repo, &meta, sub, auth.as_ref(), editing).await,
- Some((&"tree", sub)) => pages::tree_page(repo, &meta, sub, auth.as_ref()).await,
- Some((&"blob", sub)) => {
- pages::blob_page(
- repo,
- &meta,
- sub,
- auth.as_ref(),
- editing,
- pages::BlobView::Rendered,
- query_flag(query, "commented"),
- )
- .await
- }
- Some((&"source", sub)) => {
- pages::blob_page(
- repo,
- &meta,
- sub,
- auth.as_ref(),
- editing,
- pages::BlobView::Source,
- query_flag(query, "commented"),
- )
- .await
- }
- Some((&"commit", &[sha])) => pages::commit_page(repo, &meta, sha, auth.as_ref()).await,
- Some((&"releases", &[])) => pages::releases_page(repo, &meta, auth.as_ref())
- .await
- .into_response(),
- Some((&"checks", &[])) => pages::checks_page(repo, &meta, auth.as_ref())
- .await
- .into_response(),
- Some((&"checks", &[commit, name])) => {
- pages::check_recording_page(repo, &meta, commit, name, live_runs, auth.as_ref()).await
- }
- Some((&"checks", &[commit, name, "live"])) => {
- pages::check_live_fragment(repo, commit, name, live_runs).await
- }
- Some((&"checks", &[commit, name, "download"])) => {
- pages::check_recording_download(repo, commit, name, live_runs).await
- }
- Some((&"issues", &[])) => pages::issues_page(repo, &meta, auth.as_ref())
- .await
- .into_response(),
- Some((&"settings", &[])) => pages::settings_page(
- repo,
- &meta,
- auth.as_ref(),
- editing,
- query_flag(query, "saved"),
- )
- .await
- .into_response(),
- _ => not_found("No such page.").into_response(),
- }
-}
-
-/// Resolve the request's session into per-repo [`Auth`]: whether the session's
-/// web key is a member of `repo`, and under which username.
-async fn resolve_auth(repo: &Path, session: Option<write::SessionSnapshot>) -> Option<Auth> {
- let session = session?;
- let repo = repo.to_owned();
- let key = session.public_key.clone();
- let username = tokio::task::spawn_blocking(move || write::member_for_public_key(&repo, &key))
- .await
- .ok()
- .flatten();
- Some(Auth {
- label: session.label,
- username,
- csrf: session.csrf,
- })
-}
-
-/// The top-level tabs of a repository page.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum Tab {
- Overview,
- Files,
- Releases,
- Checks,
- Issues,
- Settings,
-}
-
-/// Metadata shown in the repository header band and tab bar, gathered once per
-/// request and shared by every view.
-struct RepoMeta {
- rel: String,
- branch: Option<String>,
- description: Option<String>,
- homepage: Option<String>,
- topics: Vec<String>,
- releases: usize,
- issues: usize,
-}
-
-impl RepoMeta {
- /// The repository's short name: the final segment of its path.
- fn name(&self) -> &str {
- self.rel.rsplit('/').next().unwrap_or(&self.rel)
- }
-}
-
-/// Collect the header/tab metadata for the repository at `rel`.
-async fn gather_meta(repo: &Path, rel: &str) -> RepoMeta {
- let branch = git_output(repo, &["symbolic-ref", "--short", "HEAD"])
- .await
- .map(|s| s.trim().to_owned())
- .filter(|s| !s.is_empty());
- let config = load_config(repo).await.unwrap_or_default();
- let description = Some(config.description.trim().to_owned()).filter(|s| !s.is_empty());
- let homepage = Some(config.homepage.trim().to_owned()).filter(|s| !s.is_empty());
- let topics = config
- .topics
- .into_iter()
- .map(|t| t.trim().to_owned())
- .filter(|t| !t.is_empty())
- .collect();
- let releases = git_output(repo, &["tag", "--list"])
- .await
- .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
- .unwrap_or(0);
- let issues = open_issue_count(repo).await;
- RepoMeta {
- rel: rel.to_owned(),
- branch,
- description,
- homepage,
- topics,
- releases,
- issues,
- }
-}
-
-/// Load the repository's `refs/meta/config` document off the async runtime.
-async fn load_config(repo: &Path) -> Option<git_ents_core::config::Config> {
- let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo))
- .await
- .ok()?
- .ok()
-}
-
-/// Count the repository's open issues off the async runtime.
-async fn open_issue_count(repo: &Path) -> usize {
- let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents_core::issues::open_count(&repo))
- .await
- .ok()
- .and_then(Result::ok)
- .unwrap_or(0)
-}
-
-/// Wrap a repository view in the shared header band and tab bar, then the page
-/// shell. `active` highlights the current tab.
-fn repo_shell(
- meta: &RepoMeta,
- active: Tab,
- title: &str,
- auth: Option<&Auth>,
- body: Markup,
-) -> Markup {
- page(
- title,
- html! { (account_strip_auth(auth)) (repo_header(meta)) (tab_bar(meta, active)) (body) },
- )
-}
-
-/// The repository header band: path line, branch and visibility pills,
-/// description, and topic chips.
-fn repo_header(meta: &RepoMeta) -> Markup {
- let segments: Vec<&str> = meta.rel.split('/').collect();
- let last = segments.len().saturating_sub(1);
- html! {
- div.repo-header {
- div.repo-headline {
- div.repo-path {
- (icon_folder())
- @for (i, seg) in segments.iter().enumerate() {
- @if i > 0 { span.sep { "/" } }
- @if i == last {
- span.here { (seg) }
- } @else {
- @let href = format!("/{}", segments.get(..=i).unwrap_or_default().join("/"));
- a href=(href) { (seg) }
- }
- }
- @if let Some(branch) = &meta.branch {
- span.branch { (icon_branch()) (branch) }
- }
- span.pill-public { "Public" }
- }
- @if let Some(desc) = &meta.description {
- p.repo-desc { (desc) }
- }
- @if !meta.topics.is_empty() {
- div.topics {
- @for topic in &meta.topics {
- span.topic { (topic) }
- }
- }
- }
- }
- }
- }
-}
-
-/// The tab bar with the active tab underlined. Tabs that have no backing data
-/// yet still render so the navigation matches the design.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-fn tab_bar(meta: &RepoMeta, active: Tab) -> Markup {
- let rel = &meta.rel;
- html! {
- nav.tabs {
- a.tab.active[active == Tab::Overview] href={ "/" (rel) } { "Overview" }
- a.tab.active[active == Tab::Files] href={ "/" (rel) "/files" } { "Files" }
- a.tab.active[active == Tab::Releases] href={ "/" (rel) "/releases" } {
- "Releases"
- @if meta.releases > 0 { span.tab-count { (meta.releases) } }
- }
- a.tab.active[active == Tab::Checks] href={ "/" (rel) "/checks" } { "Checks" }
- a.tab.active[active == Tab::Issues] href={ "/" (rel) "/issues" } {
- "Issues"
- @if meta.issues > 0 { span.tab-count { (meta.issues) } }
- }
- a.tab.active[active == Tab::Settings] href={ "/" (rel) "/settings" } { "Settings" }
- }
- }
-}
-
-/// The repository listing shown at `/`.
-///
-/// ## Requirements
-///
-/// @relation(web.index)
-fn index(state: &AppState, session: Option<&write::SessionSnapshot>) -> Markup {
- let repos = discover_repos(&state.data_dir);
- page(
- "Repositories",
- html! {
- (account_strip(session))
- div.page-header {
- h1.page-title { (icon_repo()) "Repositories" }
- @if !repos.is_empty() {
- span.count { (repos.len()) " repos" }
- }
- }
- @if repos.is_empty() {
- div.blankslate {
- h2 { "No repositories yet" }
- p { "Push to this server to create one:" }
- p { code { "git push <url>/my-repo.git HEAD" } }
- }
- } @else {
- ul.repo-list {
- @for repo in &repos {
- li {
- a.repo-row href={ "/" (repo) } {
- span.repo-icon { (icon_repo()) }
- span.repo-name { (repo) }
- span.repo-badge { "git" }
- span.repo-arrow { (icon_chevron()) }
- }
- }
- }
- }
- }
- },
- )
-}
-
-/// A `404` page reporting `reason`, e.g. "No such file." or "No such commit.",
-/// so a missing sub-resource does not read as a missing repository.
-fn not_found(reason: &str) -> (StatusCode, Markup) {
- (
- StatusCode::NOT_FOUND,
- page(
- "Not found",
- html! {
- div.blankslate {
- h2 { "404" }
- p { (reason) }
- a.btn href="/" { "Back to repositories" }
- }
- },
- ),
- )
-}
-
-/// A small right-aligned strip showing who is signed in, with a sign-in or
-/// sign-out control. Shared by every page shell so auth state is never
-/// ambiguous, whichever tab a visitor lands on.
-fn account_strip_view(identity: Option<(&str, &str)>) -> Markup {
- html! {
- div.account-strip {
- @match identity {
- Some((label, csrf)) => {
- span.muted { "Signed in · " (label) }
- form method="post" action="/logout" {
- input type="hidden" name="csrf" value=(csrf);
- button.btn.btn-quiet type="submit" { "Sign out" }
- }
- }
- None => a.btn.btn-quiet href="/login" { "Sign in" }
- }
- }
- }
-}
-
-fn account_strip(session: Option<&write::SessionSnapshot>) -> Markup {
- account_strip_view(session.map(|s| (s.label.as_str(), s.csrf.as_str())))
-}
-
-fn account_strip_auth(auth: Option<&Auth>) -> Markup {
- account_strip_view(auth.map(|a| (a.label.as_str(), a.csrf.as_str())))
-}
-
-/// The sign-in page: prove control of a member key by signing a one-time
-/// challenge locally, without ever surrendering the key. `error` shows a failed
-/// attempt's reason; `challenge` is the nonce to sign.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge)
-fn login_page(
- session: Option<&write::SessionSnapshot>,
- challenge: Option<&str>,
- error: Option<&str>,
- next: Option<&str>,
- signed_out: bool,
-) -> Markup {
- page(
- "Sign in",
- html! {
- (account_strip(session))
- div.page-header { h1.page-title { "Sign in" } }
- @if signed_out && session.is_none() {
- (notice_banner("Signed out."))
- }
- @if let Some(s) = session {
- p { "Signed in as " strong { (s.label) } "." }
- p.muted { "Edits you make in the browser are attributed to your member key." }
- } @else {
- p.shell-note {
- "Prove control of a web key whose public half is a member of the repository. "
- "Sign the one-time challenge below on your own machine — the key never leaves it."
- }
- @if let Some(error) = error {
- div.card-row.muted { "Could not sign in: " (error) }
- }
- @if let Some(nonce) = challenge {
- p.shell-note {
- "Have " code { "git-ents" } " installed? Run "
- code { "git ents login <remote>" }
- " instead."
- }
- p.shell-note { "Otherwise, run this, then paste the output and your public key:" }
- pre.signin-cmd {
- "printf %s '" (nonce) "' | ssh-keygen -Y sign -n "
- (write::LOGIN_NAMESPACE) " -f ~/.ssh/your_web_key"
- }
- form.edit-form method="post" action="/login" {
- input type="hidden" name="nonce" value=(nonce);
- @if let Some(next) = next {
- input type="hidden" name="next" value=(next);
- }
- label { "Public key" }
- input type="text" name="public_key" spellcheck="false"
- placeholder="ssh-ed25519 AAAA… you@host";
- label { "Signature" }
- textarea name="signature" rows="8" spellcheck="false"
- placeholder="-----BEGIN SSH SIGNATURE-----" {}
- button.btn type="submit" { "Sign in" }
- }
- } @else {
- div.card-row.muted { "Could not start a sign-in challenge; reload to retry." }
- }
- }
- },
- )
-}
-
-/// A page reporting why a browser write was rejected, with a way back to the
-/// page it was posted from.
-fn edit_error(back: &str, error: &str) -> Markup {
- page(
- "Edit rejected",
- html! {
- div.page-header { h1.page-title { "Edit rejected" } }
- div.card-row.muted { (error) }
- p { a.btn href=(back) { "Back" } }
- },
- )
-}
-
-/// Wrap page `body` in the shared HTML shell, navigation, and styling.
-// @relation(web.server-rendered)
-// live check polling) are progressive enhancement, not required for navigation
-fn page(title: &str, body: Markup) -> Markup {
- html! {
- (DOCTYPE)
- html lang="en" {
- head {
- meta charset="utf-8";
- meta name="viewport" content="width=device-width, initial-scale=1";
- title { (title) " · Git Ents" }
- link rel="preconnect" href="https://fonts.googleapis.com";
- link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
- link rel="stylesheet" href=(FONTS);
- style { (PreEscaped(STYLE)) }
- }
- body {
- nav.site-nav {
- div.nav-inner {
- a.nav-logo href="/" { (icon_logo()) "git-ents" }
- div.nav-search {
- (icon_search())
- input type="search" placeholder="Jump to file or symbol" aria-label="Search" disabled title="Not available yet";
- }
- }
- }
- main.content { (body) }
- footer.site-footer {
- div.footer-inner {
- "git-ents · served as paper-grain HTML · no JavaScript required"
- }
- }
- script { (PreEscaped(COPY_SCRIPT)) }
- script { (PreEscaped(LIVE_SCRIPT)) }
- }
- }
- }
-}
crates/git-ents-server/src/web/pages.rs
@@ -1,1560 +1,0 @@
-//! The per-tab page renderers and their view helpers. Each top-level tab is its
-//! own server-rendered route; with no client JavaScript beyond the
-//! check-recording replay page, expanding a folder or opening a file is a
-//! plain link back into these handlers.
-
-use std::collections::HashSet;
-use std::future::Future;
-use std::path::Path;
-use std::pin::Pin;
-
-use arborium::{Config, Highlighter, HtmlFormat};
-use askama::Template;
-use axum::response::{IntoResponse, Response};
-use git_anchor::{LineRange, Projection};
-use gix_date::Time;
-use gix_hash::{ObjectId, Prefix};
-use gix_object::bstr::ByteSlice;
-use gix_object::tree::Entry;
-use maud::{Markup, PreEscaped, html};
-
-use super::git::{
- browse_path, git_output, git_output_bytes, git_output_capped, languages, latest_release,
- list_tree, parse_iso, releases, root_tree,
-};
-use super::icons::*;
-use super::render::Render;
-use super::{RepoMeta, Tab, component, not_found, repo_shell};
-
-/// The largest blob or diff rendered in full. Past it a request would read an
-/// unbounded object into memory and highlight it, so the view shows a truncation
-/// notice instead — a cap on what one page can cost. 2 MiB comfortably covers
-/// real source files while ruling out the multi-hundred-MiB objects that would
-/// exhaust the server.
-///
-/// ## Requirements
-///
-/// @relation(web.syntax-highlight, nonfunctional.memory-cap)
-const MAX_RENDER_BYTES: usize = 2 * 1024 * 1024;
-
-/// Render an Askama tab-body template into [`Markup`] the Maud page shell can
-/// wrap. A template render failure is a programming error (a bad template),
-/// surfaced as an inline notice rather than a panic.
-fn render_body<T: Template>(tpl: &T) -> Markup {
- match tpl.render() {
- Ok(html) => PreEscaped(html),
- Err(err) => html! { div.card { div.card-row.muted { "Template error: " (err) } } },
- }
-}
-
-/// A single repository's overview: the rendered README beside an aside of
-/// clone, about, releases, and language cards.
-pub(super) async fn repo_page(
- repo: &Path,
- meta: &RepoMeta,
- host: Option<&str>,
- auth: Option<&super::Auth>,
-) -> Markup {
- let rel = &meta.rel;
- let updated = git_output(repo, &["log", "-1", "--format=%at"])
- .await
- .and_then(|s| s.trim().parse::<i64>().ok())
- .map(ago_seconds);
- let is_empty = updated.is_none();
- let tree = root_tree(repo, !is_empty).await;
- let readme = readme(repo, &tree).await;
- let clone_url = clone_url(host, rel);
- let langs = languages(repo).await;
- let latest = latest_release(repo).await;
- let name = meta.name();
-
- let main = html! {
- @if is_empty {
- div.blankslate {
- h2 { "This repository is empty" }
- p { "Push a commit to get started." }
- }
- } @else if let Some((file, html)) = &readme {
- div.card {
- div.card-header { (icon_file()) " " (file) }
- article.adoc-body { (PreEscaped(html)) }
- }
- } @else if !tree.is_empty() {
- div.card {
- div.card-header { "Files" }
- @for entry in &tree {
- @let name = entry.filename.to_str_lossy();
- div.card-row.is-dir[entry.mode.is_tree()] {
- a.row-link href=(entry_href(rel, "", entry)) {
- @if entry.mode.is_tree() { (icon_folder()) } @else { (icon_file()) }
- (name.as_ref())
- }
- }
- }
- }
- }
- };
-
- let aside = html! {
- aside.aside {
- div.card {
- div.card-header { "Clone" }
- div.clone {
- code { (clone_url) }
- button.copy-btn data-copy={ "git clone " (clone_url) } { "Copy" }
- }
- }
- div.card {
- div.card-header { "About" }
- @if let Some(homepage) = &meta.homepage {
- div.aside-row {
- a href=(homepage) rel="noreferrer" { (homepage) }
- }
- }
- @if let Some((lang, color, _)) = langs.first() {
- div.aside-row {
- span.dot style={ "background:" (color) } {}
- span { (lang) }
- }
- }
- @if let Some(updated) = &updated {
- div.aside-row {
- (icon_clock())
- span.muted { "Updated " (updated) }
- }
- }
- }
- @if let Some(release) = &latest {
- div.card {
- div.card-header { "Releases" span.count { (meta.releases) } }
- div.aside-row {
- (icon_tag())
- a href={ "/" (rel) "/releases" } { span.tag-pill { (release.tag) } }
- span.badge-latest { "Latest" }
- }
- div.aside-row {
- span.muted { (release.title) " · " (ago(&release.date)) }
- }
- }
- }
- @if !langs.is_empty() {
- div.card {
- div.card-header { "Languages" }
- div.lang {
- div.lang-bar {
- @for (_, color, pct) in &langs {
- span style={ "width:" (pct) "%;background:" (color) } {}
- }
- }
- ul.lang-legend {
- @for (lang, color, pct) in &langs {
- li {
- span.lang-dot style={ "background:" (color) } {}
- span { (lang) }
- span.pct { (pct) "%" }
- }
- }
- }
- }
- }
- }
- }
- };
-
- repo_shell(
- meta,
- Tab::Overview,
- name,
- auth,
- html! { div.overview { div { (main) } (aside) } },
- )
-}
-
-/// The rendered README for the overview: the first AsciiDoc or Markdown file
-/// in the root tree whose stem is `README`, converted to HTML, paired with its
-/// filename. `None` when there is no such file or it fails to render.
-async fn readme(repo: &Path, tree: &[Entry]) -> Option<(String, String)> {
- let entry = tree.iter().find(|e| {
- let name = e.filename.to_str_lossy();
- !e.mode.is_tree()
- && is_doc(&name)
- && name
- .rsplit_once('.')
- .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme"))
- })?;
- let name = entry.filename.to_str_lossy();
- let spec = format!("HEAD:{name}");
- let bytes = git_output_bytes(repo, &["cat-file", "-p", &spec]).await?;
- let html = doc_html(&name, &String::from_utf8_lossy(&bytes))?;
- Some((name.into_owned(), html))
-}
-
-/// The formatted-document HTML for `name`, when it is a prose format the forge
-/// renders (AsciiDoc via acdc, Markdown via pulldown-cmark), or `None` when it
-/// is not one or fails to render.
-fn doc_html(name: &str, text: &str) -> Option<String> {
- match crate::render::mime_for_name(name) {
- "text/asciidoc" => crate::asciidoc::to_html(text),
- "text/markdown" => Some(crate::markdown::to_html(text)),
- _ => None,
- }
-}
-
-/// The clone URL for `rel`, using the request host when known.
-fn clone_url(host: Option<&str>, rel: &str) -> String {
- match host {
- Some(host) => format!("http://{host}/{rel}"),
- None => format!("/{rel}"),
- }
-}
-
-/// The link to a tree entry: a `tree` view for directories, a `blob` view for
-/// files. `dir` is the tree's path within the repo (empty at the root).
-fn entry_href(rel: &str, dir: &str, entry: &Entry) -> String {
- let view = if entry.mode.is_tree() { "tree" } else { "blob" };
- let name = entry.filename.to_str_lossy();
- if dir.is_empty() {
- format!("/{rel}/{view}/{name}")
- } else {
- format!("/{rel}/{view}/{dir}/{name}")
- }
-}
-
-/// One row of the Files tree pane.
-struct TreeRow {
- name: String,
- path: String,
- is_dir: bool,
- depth: usize,
- expanded: bool,
- selected: bool,
-}
-
-/// Walk the tree under `dir`, emitting a row per entry and recursing into any
-/// directory in `expanded`. Boxed because the recursion is `async`.
-fn collect_rows<'a>(
- repo: &'a Path,
- dir: &'a str,
- depth: usize,
- expanded: &'a HashSet<String>,
- selected: &'a str,
- out: &'a mut Vec<TreeRow>,
-) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
- Box::pin(async move {
- let spec = if dir.is_empty() {
- "HEAD".to_owned()
- } else {
- format!("HEAD:{dir}")
- };
- for entry in list_tree(repo, &spec).await {
- let name = entry.filename.to_str_lossy();
- let is_dir = entry.mode.is_tree();
- let path = if dir.is_empty() {
- name.clone().into_owned()
- } else {
- format!("{dir}/{name}")
- };
- let is_expanded = is_dir && expanded.contains(&path);
- out.push(TreeRow {
- name: name.into_owned(),
- path: path.clone(),
- is_dir,
- depth,
- expanded: is_expanded,
- selected: !is_dir && path == selected,
- });
- if is_expanded {
- collect_rows(
- repo,
- &path,
- depth.saturating_add(1),
- expanded,
- selected,
- out,
- )
- .await;
- }
- }
- })
-}
-
-/// The Files tab: a tree pane beside a blob pane, both inside one card. With no
-/// client JavaScript, expanding a folder or opening a file is a link to
-/// `/<repo>/files/<path>`; the tree is rendered already expanded along the
-/// selected path.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn files_page(
- repo: &Path,
- meta: &RepoMeta,
- sub: &[&str],
- auth: Option<&super::Auth>,
- editing: bool,
-) -> Response {
- let rel = &meta.rel;
- let Some(selected) = browse_path(sub) else {
- return not_found("No such file or directory.").into_response();
- };
-
- // Classify the selection so the right pane shows a file and the tree expands
- // the correct ancestors.
- let kind = if selected.is_empty() {
- None
- } else {
- git_output(repo, &["cat-file", "-t", &format!("HEAD:{selected}")])
- .await
- .map(|s| s.trim().to_owned())
- };
- let selected_file = matches!(kind.as_deref(), Some("blob")).then(|| selected.clone());
- let selected_dir = match kind.as_deref() {
- Some("tree") => selected.clone(),
- Some("blob") => selected
- .rsplit_once('/')
- .map_or(String::new(), |(d, _)| d.to_owned()),
- _ if selected.is_empty() => String::new(),
- _ => return not_found("No such file or directory.").into_response(),
- };
-
- // Expand every ancestor directory of the selection (and the selection itself
- // when it is a directory).
- let mut expanded = HashSet::new();
- let mut acc = String::new();
- for part in selected_dir.split('/').filter(|s| !s.is_empty()) {
- if !acc.is_empty() {
- acc.push('/');
- }
- acc.push_str(part);
- expanded.insert(acc.clone());
- }
-
- let mut rows = Vec::new();
- collect_rows(
- repo,
- "",
- 0,
- &expanded,
- selected_file.as_deref().unwrap_or_default(),
- &mut rows,
- )
- .await;
-
- let right = match &selected_file {
- Some(path) => {
- let pane = blob_pane(repo, path).await;
- let comments = file_comments(repo, path).await;
- html! { (pane) (comments_card(&comments, comment_form(rel, path, auth, editing))) }
- }
- None => html! {
- div.files-empty {
- (icon_file())
- p { "Select a file to view its contents." }
- }
- },
- };
-
- let name = meta.name();
- let title = if selected.is_empty() {
- format!("Files · {name}")
- } else {
- format!("{selected} · {name}")
- };
- repo_shell(
- meta,
- Tab::Files,
- &title,
- auth,
- html! {
- div.files {
- div.tree-pane {
- div.tree-head { (icon_branch()) (meta.branch.as_deref().unwrap_or("HEAD")) }
- @for row in &rows {
- a.tree-row.sel[row.selected]
- href={ "/" (rel) "/files/" (row.path) }
- style={ "padding-left:" (row.depth.saturating_mul(15).saturating_add(8)) "px" }
- {
- @if row.is_dir {
- span.chev.open[row.expanded] { (icon_chevron()) }
- span.ic-folder { (icon_folder()) }
- } @else {
- span.chev {}
- span.ic-file { (icon_file()) }
- }
- span { (row.name) }
- }
- }
- }
- div.blob-pane { (right) }
- }
- },
- )
- .into_response()
-}
-
-/// The right-hand pane of the Files view: a file's path, line/size meta, and its
-/// syntax-highlighted source (or a binary notice).
-async fn blob_pane(repo: &Path, path: &str) -> Markup {
- let Some((bytes, truncated)) = git_output_capped(
- repo,
- &["cat-file", "-p", &format!("HEAD:{path}")],
- MAX_RENDER_BYTES,
- )
- .await
- else {
- return html! { div.files-empty { "File not found." } };
- };
- let name = path.rsplit('/').next().unwrap_or(path);
- html! {
- div.blob-head {
- span { (path) }
- span.meta {
- @if !truncated && !is_binary(&bytes) {
- span { (String::from_utf8_lossy(&bytes).lines().count()) " lines" }
- }
- span { (human_size(bytes.len())) @if truncated { "+" } }
- @if !truncated {
- button.copy-btn data-copy=(String::from_utf8_lossy(&bytes)) { "Copy" }
- }
- }
- }
- @if truncated {
- div.binary { "File too large to display (over " (human_size(MAX_RENDER_BYTES)) ")." }
- } @else if is_binary(&bytes) {
- div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." }
- } @else {
- (blob_body(name, &String::from_utf8_lossy(&bytes)))
- }
- }
-}
-
-/// A byte count rendered as a compact human-readable size.
-fn human_size(bytes: usize) -> String {
- const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
- #[expect(
- clippy::cast_precision_loss,
- reason = "an approximate display size; exactness past 2^52 bytes is irrelevant"
- )]
- let mut size = bytes as f64;
- let mut unit = 0;
- while size >= 1024.0 && unit < UNITS.len().saturating_sub(1) {
- size /= 1024.0;
- unit = unit.saturating_add(1);
- }
- let label = UNITS.get(unit).unwrap_or(&"B");
- if unit == 0 {
- format!("{bytes} {label}")
- } else {
- format!("{size:.1} {label}")
- }
-}
-
-/// A commit id shortened to seven hex characters for display.
-fn short_oid(oid: &ObjectId) -> String {
- Prefix::new(oid, 7)
- .ok()
- .map_or_else(|| oid.to_string(), |prefix| prefix.to_string())
-}
-
-/// A git date rendered as a relative "time ago" label, measured against the
-/// current time.
-fn ago(time: &Time) -> String {
- ago_seconds(time.seconds)
-}
-
-/// [`ago`] for a bare epoch-seconds timestamp.
-fn ago_seconds(then: i64) -> String {
- let secs = Time::now_utc().seconds.saturating_sub(then).max(0);
- let mins = secs.checked_div(60).unwrap_or(0);
- let hours = mins.checked_div(60).unwrap_or(0);
- let days = hours.checked_div(24).unwrap_or(0);
- if mins == 0 {
- "just now".to_owned()
- } else if hours == 0 {
- plural(mins, "minute")
- } else if days == 0 {
- plural(hours, "hour")
- } else if days < 30 {
- plural(days, "day")
- } else if days < 365 {
- plural(days.checked_div(30).unwrap_or(0), "month")
- } else {
- plural(days.checked_div(365).unwrap_or(0), "year")
- }
-}
-
-/// Format `n` whole `unit`s with an "ago" suffix, pluralizing as needed.
-fn plural(n: i64, unit: &str) -> String {
- if n == 1 {
- format!("1 {unit} ago")
- } else {
- format!("{n} {unit}s ago")
- }
-}
-
-/// A directory listing at `sub` within the repository.
-pub(super) async fn tree_page(
- repo: &Path,
- meta: &RepoMeta,
- sub: &[&str],
- auth: Option<&super::Auth>,
-) -> Response {
- let rel = &meta.rel;
- let Some(dir) = browse_path(sub) else {
- return not_found("No such directory.").into_response();
- };
- let spec = if dir.is_empty() {
- "HEAD".to_owned()
- } else {
- format!("HEAD:{dir}")
- };
- let entries = list_tree(repo, &spec).await;
- if entries.is_empty() && !dir.is_empty() {
- return not_found("No such directory.").into_response();
- }
- let name = meta.name();
- let title = if dir.is_empty() {
- format!("Files · {name}")
- } else {
- format!("{dir} · {name}")
- };
- repo_shell(
- meta,
- Tab::Files,
- &title,
- auth,
- html! {
- (crumbs(rel, &dir, false))
- div.card {
- div.card-header { "Files" }
- @if dir.is_empty() && entries.is_empty() {
- div.card-row { "Empty repository." }
- }
- @for entry in &entries {
- @let name = entry.filename.to_str_lossy();
- div.card-row.is-dir[entry.mode.is_tree()] {
- a.row-link href=(entry_href(rel, &dir, entry)) {
- @if entry.mode.is_tree() { (icon_folder()) } @else { (icon_file()) }
- (name.as_ref())
- }
- }
- }
- }
- },
- )
- .into_response()
-}
-
-/// Which form of a blob the blob route shows: prose formats (AsciiDoc,
-/// Markdown) rendered as a document, or the underlying source.
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub(super) enum BlobView {
- Rendered,
- Source,
-}
-
-/// A single file's contents at `sub`. A prose format renders as a formatted
-/// document under [`BlobView::Rendered`] and as its source under
-/// [`BlobView::Source`], with a toggle between the two; everything else is
-/// syntax-highlighted source when the language is recognized and the file is
-/// text.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs, web.syntax-highlight)
-pub(super) async fn blob_page(
- repo: &Path,
- meta: &RepoMeta,
- sub: &[&str],
- auth: Option<&super::Auth>,
- editing: bool,
- view: BlobView,
- commented: bool,
-) -> Response {
- let rel = &meta.rel;
- let Some(path) = browse_path(sub).filter(|p| !p.is_empty()) else {
- return not_found("No such file.").into_response();
- };
- let spec = format!("HEAD:{path}");
- if git_output(repo, &["cat-file", "-t", &spec])
- .await
- .as_deref()
- != Some("blob\n")
- {
- return not_found("No such file.").into_response();
- }
- let Some((bytes, truncated)) =
- git_output_capped(repo, &["cat-file", "-p", &spec], MAX_RENDER_BYTES).await
- else {
- return not_found("No such file.").into_response();
- };
- let name = path.rsplit('/').next().unwrap_or(&path);
- let displayable = !truncated && !is_binary(&bytes);
- let body = if truncated {
- html! { div.blob { div.binary { "File too large to display (over " (human_size(MAX_RENDER_BYTES)) ")." } } }
- } else if !displayable {
- html! { div.blob { div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." } } }
- } else {
- let text = String::from_utf8_lossy(&bytes);
- match view {
- BlobView::Rendered => match doc_html(name, &text) {
- Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } },
- None => blob_body(name, &text),
- },
- BlobView::Source => blob_body(name, &text),
- }
- };
- let comments = file_comments(repo, &path).await;
- let title = format!("{path} · {}", meta.name());
- repo_shell(
- meta,
- Tab::Files,
- &title,
- auth,
- html! {
- (crumbs(rel, &path, true))
- @if displayable && is_doc(name) {
- div.view-toggle {
- @match view {
- BlobView::Rendered => a.chip href={ "/" (rel) "/source/" (path) } { "View source" },
- BlobView::Source => a.chip href={ "/" (rel) "/blob/" (path) } { "View rendered" },
- }
- }
- }
- (body)
- @if commented {
- (super::notice_banner("Comment posted."))
- }
- (comments_card(&comments, comment_form(rel, &path, auth, editing)))
- },
- )
- .into_response()
-}
-
-/// Whether `name` is a prose format the forge renders as a document, and so
-/// gets the rendered/source toggle.
-fn is_doc(name: &str) -> bool {
- crate::render::mime_for_name(name) != "text/plain"
-}
-
-/// Render text file `source` with a line-number gutter — each number a
-/// self-linking `#L<n>` anchor — highlighting via `arborium` when the filename
-/// maps to a known grammar.
-///
-/// ## Requirements
-///
-/// @relation(web.syntax-highlight)
-fn blob_body(name: &str, source: &str) -> Markup {
- let lines = source.lines().count().max(1);
- let highlighted = highlight(name, source);
- html! {
- div.blob {
- pre.blob-nums {
- @for n in 1..=lines {
- a id={ "L" (n) } href={ "#L" (n) } { (n) }
- }
- }
- pre.blob-code {
- @match highlighted {
- Some(html) => code.code { (PreEscaped(html)) },
- None => code { (source) },
- }
- }
- }
- }
-}
-
-/// Highlighted HTML for `source`, or `None` when the filename has no grammar
-/// (in which case the caller renders escaped plain text). The highlighter is
-/// built and used synchronously so its non-`Send` grammar store is never held
-/// across an `.await`.
-///
-/// ## Requirements
-///
-/// @relation(web.syntax-highlight)
-fn highlight(name: &str, source: &str) -> Option<String> {
- let language = arborium::detect_language(name)?;
- let config = Config {
- html_format: HtmlFormat::ClassNames,
- ..Default::default()
- };
- Highlighter::with_config(config)
- .highlight(language, source)
- .ok()
-}
-
-/// Whether `bytes` looks like binary content (a NUL byte in the leading chunk,
-/// the same heuristic git uses).
-fn is_binary(bytes: &[u8]) -> bool {
- bytes.iter().take(8000).any(|b| *b == 0)
-}
-
-/// A comment as a file view shows it: who wrote it and when, where its anchor
-/// lands on `HEAD`, and its body, rendered as AsciiDoc (a comment carries no
-/// filename to infer a MIME type from, so it gets the forge's default prose
-/// treatment — see [`crate::render::DEFAULT_PROSE_MIME`]).
-struct FileComment {
- author: String,
- seconds: i64,
- lines: Option<LineRange>,
- outdated: bool,
- body_html: String,
-}
-
-/// The comments whose anchors project onto `path` at `HEAD`, read off the
-/// async runtime since git-comment reads the object database synchronously.
-/// Comments that fail to project (say, an anchor commit the repository no
-/// longer has) are skipped rather than failing the page.
-///
-/// ## Requirements
-///
-/// @relation(web.comments)
-async fn file_comments(repo: &Path, path: &str) -> Vec<FileComment> {
- let repo = repo.to_owned();
- let path = path.to_owned();
- tokio::task::spawn_blocking(move || {
- let Ok(comments) = git_comment::list(&repo) else {
- return Vec::new();
- };
- let mut out = Vec::new();
- for (id, comment) in comments {
- let Ok(projection) = git_comment::project(&repo, &id, "HEAD") else {
- continue;
- };
- let (landed, lines, outdated) = match projection {
- Projection::Current => (comment.anchor.path.clone(), comment.anchor.lines, false),
- Projection::Relocated { path, lines } => (path, lines, false),
- Projection::Outdated { path } => (path, None, true),
- Projection::FileDeleted => continue,
- };
- if landed != path {
- continue;
- }
- let provenance = git_comment::provenance(&repo, &id).ok().flatten();
- out.push(FileComment {
- author: provenance
- .as_ref()
- .map_or_else(|| "?".to_owned(), |p| p.created.name.clone()),
- seconds: provenance
- .map_or(0, |p| i64::try_from(p.created.seconds).unwrap_or(i64::MAX)),
- lines,
- outdated,
- body_html: crate::render::to_html(crate::render::DEFAULT_PROSE_MIME, &comment.body),
- });
- }
- out
- })
- .await
- .unwrap_or_default()
-}
-
-/// The Comments card under a file view: existing comments, then the add form
-/// when the viewer may comment; nothing when there are neither. A
-/// line-anchored comment links its range to the gutter's `#L<n>` anchors; an
-/// outdated one is flagged instead, since its lines no longer exist.
-///
-/// ## Requirements
-///
-/// @relation(web.comments)
-fn comments_card(comments: &[FileComment], form: Option<Markup>) -> Markup {
- if comments.is_empty() && form.is_none() {
- return html! {};
- }
- html! {
- div.card.file-comments {
- div.card-header { "Comments (" (comments.len()) ")" }
- @for comment in comments {
- div.comment-row {
- div.comment-meta {
- span.author { (comment.author) }
- @if comment.seconds > 0 { span { (ago_seconds(comment.seconds)) } }
- @if let Some(range) = comment.lines {
- a.chip href={ "#L" (range.start) } {
- @if range.start == range.end { "line " (range.start) }
- @else { "lines " (range.start) "\u{2013}" (range.end) }
- }
- }
- @if comment.outdated { span.chip { "outdated" } }
- }
- div.comment-body { (PreEscaped(&comment.body_html)) }
- }
- }
- @if let Some(form) = form { (form) }
- }
- }
-}
-
-/// The add-comment form under a file view, shown when a signed-in member views
-/// a server that can land edits; `None` otherwise, since a submit would only
-/// fail. The comment anchors to `HEAD`'s blob at `path`.
-///
-/// ## Requirements
-///
-/// @relation(web.comments)
-fn comment_form(
- rel: &str,
- path: &str,
- auth: Option<&super::Auth>,
- editing: bool,
-) -> Option<Markup> {
- let auth = auth.filter(|a| editing && a.username.is_some())?;
- Some(html! {
- div.comment-row {
- form.edit-form method="post" action={ "/" (rel) "/comment" } {
- input type="hidden" name="csrf" value=(auth.csrf);
- input type="hidden" name="path" value=(path);
- label { "Lines" }
- input type="text" name="lines" placeholder="12 or 12:15 — empty for the whole file";
- label { "Comment" }
- textarea name="body" rows="3" placeholder="Anchored to this file as of the current HEAD" {}
- button.btn type="submit" { "Comment" }
- }
- }
- })
-}
-
-/// A single commit: its metadata and a colorized unified diff.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn commit_page(
- repo: &Path,
- meta: &RepoMeta,
- sha: &str,
- auth: Option<&super::Auth>,
-) -> Response {
- if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
- return not_found("No such commit.").into_response();
- }
- let Some(info) = git_output(
- repo,
- &["show", "-s", "--format=%H%x00%an%x00%aI%x00%s%x00%b", sha],
- )
- .await
- else {
- return not_found("No such commit.").into_response();
- };
- let mut parts = info.split('\u{0}');
- let Some(oid) = parts
- .next()
- .and_then(|h| ObjectId::from_hex(h.trim().as_bytes()).ok())
- else {
- return not_found("No such commit.").into_response();
- };
- let author = parts.next().unwrap_or_default().to_owned();
- let when = parts.next().and_then(parse_iso);
- let subject = parts.next().unwrap_or_default().to_owned();
- let body = parts.next().unwrap_or_default().trim_end().to_owned();
- let short = short_oid(&oid);
- let (patch_bytes, patch_truncated) = git_output_capped(
- repo,
- &["show", "--no-color", "--format=", "--patch", sha],
- MAX_RENDER_BYTES,
- )
- .await
- .unwrap_or_default();
- let patch = String::from_utf8_lossy(&patch_bytes);
-
- repo_shell(
- meta,
- Tab::Files,
- &subject,
- auth,
- html! {
- div.card {
- div.card-header {
- (icon_commit()) " Commit "
- a.sha href={ "/" (meta.rel) "/tree/" (oid) } { (short) }
- }
- div.commit {
- div.commit-subject { (subject) }
- @if !body.is_empty() {
- div.commit-msg { (body) }
- }
- div.commit-meta { (author) @if let Some(when) = &when { " · " (ago(when)) } }
- }
- }
- (diff_view(&patch))
- @if patch_truncated {
- div.card { div.binary { "Diff truncated (over " (human_size(MAX_RENDER_BYTES)) ")." } }
- }
- },
- )
- .into_response()
-}
-
-/// The Releases tab: tags presented as a changelog timeline, newest first.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn releases_page(
- repo: &Path,
- meta: &RepoMeta,
- auth: Option<&super::Auth>,
-) -> Markup {
- let releases = releases(repo).await;
- let title = format!("Releases · {}", meta.name());
- repo_shell(
- meta,
- Tab::Releases,
- &title,
- auth,
- html! {
- div.page-header { h1.page-title { "Releases" } }
- @if releases.is_empty() {
- div.blankslate {
- h2 { "No releases yet" }
- p { "Push a tag to publish a release: " code { "git push <url> v1.0.0" } }
- }
- } @else {
- div.timeline {
- @for (i, release) in releases.iter().enumerate() {
- article.release.latest[i == 0] {
- div.card {
- div.release-head {
- (icon_tag())
- span.release-tag { (release.tag) }
- @if !release.title.is_empty() && release.title != release.tag {
- span.release-name { (release.title) }
- }
- @if i == 0 { span.badge-latest { "Latest" } }
- span.release-date { (ago(&release.date)) }
- }
- @if !release.body.is_empty() {
- div.release-body { p { (release.body) } }
- }
- div.release-foot {
- a.sha href={ "/" (meta.rel) "/commit/" (release.oid) } {
- (icon_commit()) (short_oid(&release.oid))
- }
- }
- }
- }
- }
- }
- }
- },
- )
-}
-
-/// The Checks tab. The effect set lives one ref per effect under
-/// `refs/meta/effects` (managed with `git ents effect`); each push queues them
-/// and a worker runs them in a Sprite. "Checks on HEAD" mirrors a GitHub PR
-/// checks list — one row per configured effect, its latest status against the
-/// current commit, linked to its recorded terminal session when it has one;
-/// Recent runs and Configuration below it are the full history and the raw
-/// set, as before.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn checks_page(
- repo: &Path,
- meta: &RepoMeta,
- auth: Option<&super::Auth>,
-) -> Markup {
- let rel = &meta.rel;
- let checks = component::load::<git_effect::Effect>(repo).await;
- let runs = load_runs(repo).await;
- let head = git_output(repo, &["rev-parse", "HEAD"])
- .await
- .map(|out| out.trim().to_owned())
- .filter(|head| !head.is_empty());
- let head_oid = head
- .as_deref()
- .and_then(|head| ObjectId::from_hex(head.as_bytes()).ok());
- let head_run = head_oid.and_then(|head_oid| {
- runs.as_ref()
- .ok()
- .and_then(|commits| commits.iter().find(|commit| commit.commit == head_oid))
- .and_then(|commit| commit.runs.first())
- });
- let title = format!("Checks · {}", meta.name());
- repo_shell(
- meta,
- Tab::Checks,
- &title,
- auth,
- html! {
- div.page-header { h1.page-title { "Checks" } }
- p.shell-note {
- "Checks are configured on " code { "refs/meta/effects/<name>" }
- " (" code { "git ents effect list" } ") and run in a Sprite after each push; "
- "each run is recorded under " code { "refs/meta/results/<effect>/<commit>" } "."
- }
- div.card {
- div.card-header {
- "Checks on HEAD"
- @if let Some(run) = head_run {
- span.muted { " · " (ago_seconds(i64::try_from(run.at).unwrap_or(i64::MAX))) }
- }
- }
- @match &checks {
- Err(err) => div.card-row.muted { "Could not read checks: " (err) }
- Ok(checks) if checks.is_empty() => {
- div.card-row.muted {
- "No effects configured on " code { "refs/meta/effects" } "."
- }
- }
- Ok(checks) => {
- @match head.as_deref() {
- None => div.card-row.muted { "HEAD has no commits yet." }
- Some(head) => {
- @for check in checks {
- (head_check_row(rel, head, check, head_run))
- }
- }
- }
- }
- }
- }
- div.checks-grid {
- div.card {
- div.card-header { "Recent runs" }
- @match &runs {
- Err(err) => div.card-row.muted { "Could not read runs: " (err) }
- Ok(commits) if commits.is_empty() => div.card-row.muted { "No runs recorded yet." }
- Ok(commits) => {
- @for commit in commits.iter().take(25) {
- @for run in &commit.runs {
- div.card-row.signer-row {
- code.key { (short_oid(&commit.commit)) }
- (super::render::run_row(rel, &commit.commit.to_string(), run))
- span.muted {
- (ago_seconds(i64::try_from(run.at).unwrap_or(i64::MAX)))
- }
- }
- }
- }
- }
- }
- }
- (component::card(&checks))
- }
- },
- )
-}
-
-/// One check's row on the "Checks on HEAD" card: its name and its latest status
-/// against `head`, linked to its recorded terminal session when `head_run`
-/// carries one for it. A check with no outcome yet on `head` (just added, or
-/// its run has not landed) reads "no run yet" rather than a stale result.
-fn head_check_row(
- rel: &str,
- head: &str,
- check: &git_effect::Effect,
- head_run: Option<&git_effect::Run>,
-) -> Markup {
- let outcome =
- head_run.and_then(|run| run.results.iter().find(|result| result.name == check.name));
- let href = format!("/{rel}/checks/{head}/{}", check.name);
- html! {
- div.card-row.signer-row {
- (super::render::check_list_row(&check.name, outcome, &href))
- }
- }
-}
-
-/// Find `name`'s outcome in `commit`'s latest recorded run, or `None` when
-/// `commit` has no run, or no result under that name.
-async fn latest_outcome(
- repo: &Path,
- commit_oid: ObjectId,
- name: &str,
-) -> Option<git_effect::RunOutcome> {
- load_runs(repo)
- .await
- .ok()?
- .into_iter()
- .find(|commit_runs| commit_runs.commit == commit_oid)
- .and_then(|commit_runs| commit_runs.runs.into_iter().next())
- .and_then(|run| run.results.into_iter().find(|result| result.name == name))
-}
-
-/// One check's terminal session on `commit` — reached by clicking a linked
-/// status on the "Checks on HEAD" card. While the check is still `queued` or
-/// `running` this is a live view, polling [`check_live_fragment`] until the
-/// check settles; once it has, it replays the finished recording with
-/// `asciinema-player`, or reports the exit code plain when there was no
-/// output to replay. 404s when `commit` has no run recorded or `name` is not
-/// among its results.
-pub(super) async fn check_recording_page(
- repo: &Path,
- meta: &RepoMeta,
- commit: &str,
- name: &str,
- live_runs: &git_effect::engine::LiveRegistry,
- auth: Option<&super::Auth>,
-) -> Response {
- let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
- return not_found("No such check run.").into_response();
- };
- let Some(outcome) = latest_outcome(repo, commit_oid, name).await else {
- return not_found("No such check run.").into_response();
- };
- let short_commit = commit.get(..8).unwrap_or(commit);
- let rel = &meta.rel;
-
- let body = if super::render::is_in_progress(outcome.status) {
- let key = (repo.to_owned(), commit_oid, name.to_owned());
- let fragment_url = format!("/{rel}/checks/{commit}/{name}/live");
- let live = git_effect::engine::live_snapshot(live_runs, &key);
- let initial = super::render::live_fragment_body(live.clone());
- let download_href = format!("/{rel}/checks/{commit}/{name}/download");
- html! {
- p.shell-note {
- "This check is still " (outcome.status.to_string()) "; the view below updates live."
- @if live.is_some() {
- " " a.btn-quiet href=(download_href) download { "Download raw log so far" }
- }
- }
- style { (PreEscaped(crate::asciidoc::TERMINAL_VIEW_CSS)) }
- div #live-terminal data-live-check=(fragment_url) { (initial) }
- }
- } else {
- let download_href = format!("/{rel}/checks/{commit}/{name}/download");
- super::render::check_result_view(&outcome, &download_href)
- };
- repo_shell(
- meta,
- Tab::Checks,
- &format!("{name} @ {short_commit}"),
- auth,
- html! {
- div.page-header {
- h1.page-title {
- (name) " on "
- a href={ "/" (rel) "/commit/" (commit) } { code { (short_commit) } }
- }
- }
- (body)
- },
- )
- .into_response()
-}
-
-/// One poll of a running check's live output — the fragment [`LIVE_SCRIPT`]
-/// swaps into the run page's `#live-terminal` container. Reports its state via
-/// the `X-Check-Live` response header rather than the body, so the script can
-/// tell the three cases apart: `running` (a live buffer exists), `done` (no
-/// live buffer, and the persisted result has actually settled), or `stale` (no
-/// live buffer, but the persisted result still reads queued/running — the
-/// worker hasn't caught up yet). Reporting `done` in the `stale` case is what
-/// used to send the script into a reload loop, since the reloaded page would
-/// still pick the live branch and poll straight back into "done".
-///
-/// [`LIVE_SCRIPT`]: super::assets::LIVE_SCRIPT
-pub(super) async fn check_live_fragment(
- repo: &Path,
- commit: &str,
- name: &str,
- live_runs: &git_effect::engine::LiveRegistry,
-) -> Response {
- let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
- return not_found("No such check run.").into_response();
- };
- let key = (repo.to_owned(), commit_oid, name.to_owned());
- let recording = git_effect::engine::live_snapshot(live_runs, &key);
- let header = if recording.is_some() {
- "running"
- } else {
- match latest_outcome(repo, commit_oid, name).await {
- Some(outcome) if super::render::is_in_progress(outcome.status) => "stale",
- _ => "done",
- }
- };
- let body = super::render::live_fragment_body(recording).into_string();
- ([("x-check-live", header)], body).into_response()
-}
-
-/// Download a check's raw asciicast recording, for replaying outside the
-/// browser (`asciinema play <file>`) or archiving. While the check is still
-/// running this hands out the live buffer captured so far instead of the
-/// (not yet existing) settled recording. 404s under the same conditions as
-/// [`check_recording_page`] (no run recorded, or none for `name`), and also
-/// when neither a settled recording nor a live buffer is available.
-pub(super) async fn check_recording_download(
- repo: &Path,
- commit: &str,
- name: &str,
- live_runs: &git_effect::engine::LiveRegistry,
-) -> Response {
- let Some(commit_oid) = ObjectId::from_hex(commit.as_bytes()).ok() else {
- return not_found("No such check run.").into_response();
- };
- let Some(outcome) = latest_outcome(repo, commit_oid, name).await else {
- return not_found("No such check run.").into_response();
- };
- let live = super::render::is_in_progress(outcome.status)
- .then(|| {
- let key = (repo.to_owned(), commit_oid, name.to_owned());
- git_effect::engine::live_snapshot(live_runs, &key)
- })
- .flatten();
- let Some(recording) = live.or(outcome.recording) else {
- return not_found("No recording available for this check run.").into_response();
- };
- let short_commit = commit.get(..8).unwrap_or(commit);
- let filename = format!(
- "{}-{}.cast",
- sanitize_filename(name),
- sanitize_filename(short_commit)
- );
- (
- [
- ("content-type", "application/x-asciicast".to_owned()),
- (
- "content-disposition",
- format!("attachment; filename=\"{filename}\""),
- ),
- ],
- recording,
- )
- .into_response()
-}
-
-/// Keep only characters safe for a `Content-Disposition` filename, so a check
-/// name can't inject header syntax into the download response.
-fn sanitize_filename(s: &str) -> String {
- s.chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
- c
- } else {
- '_'
- }
- })
- .collect()
-}
-
-/// Load the recorded runs off the async runtime, like [`component::load`].
-async fn load_runs(repo: &Path) -> Result<Vec<git_effect::CommitRuns>, String> {
- let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_effect::runs(&repo))
- .await
- .map_err(|err| err.to_string())?
- .map_err(|err| err.to_string())
-}
-
-/// The Issues tab: the real issue list from
-/// `refs/meta/issues/<id>`, split into open and closed, with the filter chips
-/// derived from the labels that exist. Issue creation is a write path that does
-/// not exist yet, so the "New issue" button stays disabled.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn issues_page(
- repo: &Path,
- meta: &RepoMeta,
- auth: Option<&super::Auth>,
-) -> Markup {
- let tpl = match component::load::<git_ents_core::issues::Issue>(repo).await {
- Err(err) => IssuesTemplate {
- icons: Icons,
- error: Some(err),
- labels: Vec::new(),
- open: Vec::new(),
- open_count: 0,
- closed_count: 0,
- },
- Ok(issues) => {
- let open: Vec<&git_ents_core::issues::Issue> =
- issues.iter().filter(|issue| issue.is_open()).collect();
- let closed = issues.len().saturating_sub(open.len());
- let mut labels: Vec<String> = issues
- .iter()
- .flat_map(|issue| issue.labels.iter().cloned())
- .collect();
- labels.sort_unstable();
- labels.dedup();
- IssuesTemplate {
- icons: Icons,
- error: None,
- labels,
- open_count: open.len(),
- closed_count: closed,
- open: open
- .iter()
- .map(|issue| issue.render().into_string())
- .collect(),
- }
- }
- };
- let title = format!("Issues · {}", meta.name());
- repo_shell(meta, Tab::Issues, &title, auth, render_body(&tpl))
-}
-
-/// The Issues tab body: the open/closed filter and per-issue cards.
-#[derive(Template)]
-#[template(path = "issues.html")]
-struct IssuesTemplate {
- icons: Icons,
- error: Option<String>,
- labels: Vec<String>,
- open: Vec<String>,
- open_count: usize,
- closed_count: usize,
-}
-
-/// The Settings tab: a projection over the repository's typed meta refs —
-/// `refs/meta/config` (General), `refs/meta/members` (Members), and the derived
-/// feature and check status. The General fields are editable in place by a
-/// signed-in member when `editing` is set (the server has a signing key and the
-/// gate); everything else is read-only.
-///
-/// ## Requirements
-///
-/// @relation(web.tabs)
-pub(super) async fn settings_page(
- repo: &Path,
- meta: &RepoMeta,
- auth: Option<&super::Auth>,
- editing: bool,
- saved: bool,
-) -> Markup {
- let members = component::load::<git_member::members::Member>(repo).await;
- let checks = component::load::<git_effect::Effect>(repo).await;
- let config = load_repo_config(repo).await;
- let title = format!("Settings · {}", meta.name());
- repo_shell(
- meta,
- Tab::Settings,
- &title,
- auth,
- html! {
- div.settings {
- div.page-header { h1.page-title { "Repository settings" } }
- p.shell-note {
- "The repository's configuration on " code { "refs/meta/config" }
- " and " code { "refs/meta/members" } "."
- }
- @if saved {
- (super::notice_banner("Settings saved."))
- }
- (settings_auth_banner(auth, editing))
-
- div.card {
- div.card-header { "General" }
- (setting_row("Repository name", meta.name()))
- (setting_row("Default branch", meta.branch.as_deref().unwrap_or("—")))
- (general_settings(meta, auth, editing))
- }
-
- div.card {
- div.card-header { "Features" }
- (feature_row("Issues", "Track and triage bugs.", meta.issues > 0))
- (feature_row("Releases", "Publish tagged releases.", meta.releases > 0))
- (feature_row("Checks (CI)", "Run signed CI records on push.", matches!(&checks, Ok(c) if !c.is_empty())))
- }
-
- p.shell-note {
- "People on " code { "refs/meta/member/*" } " whose signed pushes are accepted "
- "(" code { "git ents members list" } ")."
- }
- (component::card(&members))
-
- p.shell-note {
- "Commands on " code { "refs/meta/effects/*" } " run against each push "
- "(" code { "git ents effect list" } ")."
- }
- (component::card(&checks))
-
- div.card {
- div.card-header { "Roles" }
- p.shell-note {
- "Ref-push gating by member role, on " code { "refs/meta/config" }
- " — members join a role with " code { "git ents members add --role" } "."
- }
- @match &config {
- Err(err) => div.card-row.muted { "Could not read config: " (err) }
- Ok(config) if config.roles.is_empty() => {
- div.card-row.muted {
- "No roles configured — every member may push any ref."
- }
- }
- Ok(config) => (config.render())
- }
- }
- }
- },
- )
-}
-
-/// Load `refs/meta/config` off the async runtime, like [`component::load`].
-async fn load_repo_config(repo: &Path) -> Result<git_ents_core::config::Config, String> {
- let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo))
- .await
- .map_err(|err| err.to_string())?
- .map_err(|err| err.to_string())
-}
-
-/// The settings authorization banner: who is signed in and whether they may
-/// edit this repository. When `editing` is unset the server cannot land edits at
-/// all, so a member is told editing is disabled rather than offered controls
-/// that would only fail.
-fn settings_auth_banner(auth: Option<&super::Auth>, editing: bool) -> Markup {
- html! {
- @match auth {
- None => p.auth-banner {
- a href="/login" { "Sign in" } " with a member web key to edit these settings."
- }
- Some(auth) if auth.username.is_some() && editing => p.auth-banner.auth-banner-active {
- "Signed in as " strong { (auth.label) } " — you can edit this repository."
- }
- Some(auth) if auth.username.is_some() => p.auth-banner {
- "Signed in as " strong { (auth.label) } ", but this server has browser editing "
- "disabled, so settings are read-only."
- }
- Some(auth) => p.auth-banner {
- "Signed in as " strong { (auth.label) } ", but this key is not a member of this "
- "repository, so settings are read-only."
- }
- }
- }
-}
-
-/// The editable General fields (description, homepage, topics): an edit form when
-/// a signed-in member edits a server that can land edits, otherwise read-only rows.
-fn general_settings(meta: &RepoMeta, auth: Option<&super::Auth>, editing: bool) -> Markup {
- let Some(auth) = auth.filter(|a| editing && a.username.is_some()) else {
- return html! {
- (setting_row("Description", meta.description.as_deref().unwrap_or("—")))
- (setting_row("Homepage", meta.homepage.as_deref().unwrap_or("—")))
- div.card-row {
- span.setting-label { "Topics" }
- @if meta.topics.is_empty() {
- span.muted { "—" }
- } @else {
- div.topics { @for topic in &meta.topics { span.topic { (topic) } } }
- }
- }
- };
- };
- let description = meta.description.as_deref().unwrap_or_default();
- let homepage = meta.homepage.as_deref().unwrap_or_default();
- let topics = meta.topics.join(", ");
- html! {
- div.card-row {
- form.edit-form method="post" action={ "/" (meta.rel) "/settings" } {
- input type="hidden" name="csrf" value=(auth.csrf);
- label { "Description" }
- input type="text" name="description" value=(description)
- placeholder="A short description";
- label { "Homepage" }
- input type="text" name="homepage" value=(homepage)
- placeholder="https://example.com";
- label { "Topics" }
- input type="text" name="topics" value=(topics)
- placeholder="comma, separated, topics";
- button.btn type="submit" { "Save changes" }
- }
- }
- }
-}
-
-/// A read-only setting row: a label and its current value.
-fn setting_row(label: &str, value: &str) -> Markup {
- html! {
- div.card-row {
- span.setting-label { (label) }
- span.muted { (value) }
- }
- }
-}
-
-/// A Features row showing the derived, read-only status of `title`: active when
-/// the feature has backing data, empty otherwise.
-fn feature_row(title: &str, desc: &str, on: bool) -> Markup {
- html! {
- div.feature-row {
- div {
- strong { (title) }
- p.muted { (desc) }
- }
- span.feature-status.on[on] { @if on { "Active" } @else { "Empty" } }
- }
- }
-}
-
-/// Render a unified diff, coloring each line by its leading marker.
-fn diff_view(patch: &str) -> Markup {
- if patch.trim().is_empty() {
- return html! {};
- }
- html! {
- div.diff {
- @for line in patch.lines() {
- span class={ "ln " (diff_class(line)) } { (line) "\n" }
- }
- }
- }
-}
-
-/// The CSS class for a diff line, chosen from its leading marker.
-fn diff_class(line: &str) -> &'static str {
- if line.starts_with("@@") {
- "hunk"
- } else if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") {
- "file"
- } else if line.starts_with("index ")
- || line.starts_with("new file")
- || line.starts_with("deleted file")
- || line.starts_with("old mode")
- || line.starts_with("new mode")
- || line.starts_with("rename ")
- || line.starts_with("similarity ")
- || line.starts_with("Binary files")
- {
- "meta"
- } else if line.starts_with('+') {
- "add"
- } else if line.starts_with('-') {
- "del"
- } else {
- "ctx"
- }
-}
-
-/// One segment of a breadcrumb trail: a label and, unless it is the current
-/// file, the link to its directory listing.
-struct Crumb {
- label: String,
- href: Option<String>,
-}
-
-/// Breadcrumb navigation from the repository root down through `path`. When
-/// `is_file` is set, the final component is shown as plain text rather than a
-/// link, since a file has no listing of its own.
-fn crumbs(rel: &str, path: &str, is_file: bool) -> Markup {
- let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
- let mut acc = String::new();
- let mut trail: Vec<Crumb> = Vec::new();
- for (i, part) in parts.iter().enumerate() {
- if !acc.is_empty() {
- acc.push('/');
- }
- acc.push_str(part);
- let is_last = i.saturating_add(1) == parts.len();
- let href = (!(is_last && is_file)).then(|| format!("/{rel}/tree/{acc}"));
- trail.push(Crumb {
- label: (*part).to_owned(),
- href,
- });
- }
- html! {
- nav.crumbs {
- a href={ "/" (rel) } { (rel) }
- @for crumb in &trail {
- span.sep { "/" }
- @match &crumb.href {
- Some(href) => a href=(href) { (crumb.label) },
- None => span.here { (crumb.label) },
- }
- }
- }
- }
-}
crates/git-ents-server/src/web/render.rs
@@ -1,383 +1,0 @@
-//! Turning typed `refs/meta/*` documents into HTML.
-//!
-//! Every value the meta refs carry is a [`facet::Facet`] type, so one structural
-//! walk over its reflected shape can render any of them: a struct becomes a row
-//! (its first field the key, the rest a muted value), a map becomes one keyed
-//! row per entry, a list stacks its items, and a scalar is its text. That walk
-//! is the [`Render`] trait's default, so a new meta-ref type renders for free.
-//! Types whose presentation needs domain knowledge — a signer's shortened key, a
-//! run's one-line summary — override [`Render::render`] instead.
-
-use std::path::Path;
-
-use facet::{Def, Facet, Peek, Type, UserType};
-use maud::{Markup, PreEscaped, html};
-
-use git_effect::{Effect, Run, RunOutcome, Status};
-use git_ents_core::config::{Config, RoleRules};
-use git_ents_core::issues::Issue;
-use git_member::members::Member;
-
-use super::component::{Loadable, WebComponent};
-use crate::asciidoc;
-
-/// HTML rendering for a meta-ref value. The default walks the value's [`Facet`]
-/// shape structurally; a type overrides [`render`](Render::render) when its
-/// presentation needs more than the structure carries.
-pub(super) trait Render: for<'a> Facet<'a> {
- /// Render `self` as a fragment of card rows.
- fn render(&self) -> Markup {
- render_peek(Peek::new(self))
- }
-}
-
-/// An effect's name is the key and its command the value — `(composite)` for
-/// an effect with none — with its image, dependencies, and toolchains
-/// appended as ` · `-joined annotations rather than the raw `Option`/`Vec`
-/// the structural walk would print.
-impl Render for Effect {
- fn render(&self) -> Markup {
- let mut value = self
- .command
- .clone()
- .unwrap_or_else(|| "(composite)".to_owned());
- if let Some(image) = &self.image {
- value.push_str(&format!(" · image {image}"));
- }
- if !self.depends.is_empty() {
- value.push_str(&format!(" · needs {}", self.depends.join(", ")));
- }
- if !self.toolchains.is_empty() {
- value.push_str(&format!(" · toolchains {}", self.toolchains.join(", ")));
- }
- row(&self.name, &value)
- }
-}
-
-impl Loadable for Effect {
- fn load(repo: &Path) -> Result<Vec<Self>, String> {
- git_effect::load_all(repo).map_err(|err| err.to_string())
- }
-}
-
-impl WebComponent for Effect {
- const TITLE: &'static str = "Checks";
-
- fn empty() -> Markup {
- html! { div.card-row.muted { "No effects configured on " code { "refs/meta/effects" } "." } }
- }
-}
-
-/// Config's editable fields (description, homepage, topics) get their own
-/// edit-form treatment in the settings page, so the only piece left to render
-/// here is `roles` — one row per role rather than the raw map the structural
-/// default would otherwise print.
-impl Render for Config {
- fn render(&self) -> Markup {
- html! {
- @for (role, rules) in &self.roles {
- (role_row(role, rules))
- }
- }
- }
-}
-
-/// One role's ref-push gating: its `allow`/`deny` glob lists joined for
-/// display, or "no rules" for a role entry with neither (matches every ref,
-/// same as no entry at all).
-fn role_row(role: &str, rules: &RoleRules) -> Markup {
- let mut parts = Vec::new();
- if !rules.allow.is_empty() {
- parts.push(format!("allow {}", rules.allow.join(", ")));
- }
- if !rules.deny.is_empty() {
- parts.push(format!("deny {}", rules.deny.join(", ")));
- }
- if parts.is_empty() {
- parts.push("no rules".to_owned());
- }
- row(role, &parts.join(" · "))
-}
-
-/// An issue's title leads the row, its labels render as chips beside it rather
-/// than the raw ` · `-joined list the structural walk would print.
-impl Render for Issue {
- fn render(&self) -> Markup {
- html! {
- div.card-row.issue-row {
- span.issue-title { (self.title) }
- @for label in &self.labels {
- span.chip { (label) }
- }
- }
- }
- }
-}
-
-impl Loadable for Issue {
- fn load(repo: &Path) -> Result<Vec<Self>, String> {
- Ok(git_ents_core::issues::list(repo)
- .map_err(|err| err.to_string())?
- .into_iter()
- .map(|(_id, issue)| issue)
- .collect())
- }
-}
-
-/// A member renders one row per authorized key — the username as the key column,
-/// a short key label beside it — or a single `cert-authority` row for a pinned
-/// CA, rather than the raw keys and trust enum the structural walk would print.
-impl Render for Member {
- fn render(&self) -> Markup {
- if let Some(ca) = self.ca() {
- return row(
- &self.principal,
- &format!("cert-authority · {}", signer_label(ca)),
- );
- }
- html! {
- @for (_fingerprint, key) in self.keys() {
- (row(&self.principal, &signer_label(key)))
- }
- }
- }
-}
-
-impl Loadable for Member {
- fn load(repo: &Path) -> Result<Vec<Self>, String> {
- git_member::members::load_all(repo).map_err(|err| err.to_string())
- }
-}
-
-impl WebComponent for Member {
- const TITLE: &'static str = "Members";
-
- fn empty() -> Markup {
- html! {
- div.card-row.muted { "No members — pushes are open until the first key is added." }
- }
- }
-}
-
-/// A run's per-check outcomes, each colored by [`status_badge`] and linked to
-/// its recording when it has one — the same treatment "Checks on HEAD" gives
-/// each check, rather than one plain summary line.
-pub(super) fn run_row(rel: &str, commit_hex: &str, run: &Run) -> Markup {
- html! {
- div.run-results {
- @for outcome in &run.results {
- (run_result(rel, commit_hex, outcome))
- }
- }
- }
-}
-
-/// One outcome within a run row: its check name and status badge, linked to
-/// `/{rel}/checks/{commit_hex}/{name}` when there's a live view or a
-/// recording behind it.
-fn run_result(rel: &str, commit_hex: &str, outcome: &RunOutcome) -> Markup {
- let href = format!("/{rel}/checks/{commit_hex}/{}", outcome.name);
- let duration = html! {
- @if let Some(secs) = outcome.duration_secs {
- " " span.muted { "(" (secs) "s)" }
- }
- };
- html! {
- @if outcome.recording.is_some() || is_in_progress(outcome.status) {
- a.run-result href=(href) { (outcome.name) " " (status_badge(outcome.status)) }
- (duration)
- } @else {
- span.run-result { (outcome.name) " " (status_badge(outcome.status)) }
- (duration)
- }
- }
-}
-
-/// Render any reflected value as card rows by walking its shape.
-fn render_peek(peek: Peek<'_, '_>) -> Markup {
- match peek.shape().def {
- Def::Scalar => return html! { (scalar_text(&peek)) },
- Def::Map(_) => {
- let Ok(map) = peek.into_map() else {
- return html! {};
- };
- return html! {
- @for (key, value) in map.iter() {
- (row(&scalar_text(&key), &scalar_text(&value)))
- }
- };
- }
- Def::List(_) | Def::Array(_) | Def::Slice(_) => {
- let Ok(list) = peek.into_list_like() else {
- return html! {};
- };
- return html! { @for item in list.iter() { (render_peek(item)) } };
- }
- _ => {}
- }
- if let Type::User(UserType::Struct(st)) = peek.shape().ty {
- let Ok(strukt) = peek.into_struct() else {
- return html! {};
- };
- let mut key = String::new();
- let mut rest: Vec<String> = Vec::new();
- for index in 0..st.fields.len() {
- let Ok(field) = strukt.field(index) else {
- continue;
- };
- let text = scalar_text(&field);
- if index == 0 {
- key = text;
- } else {
- rest.push(text);
- }
- }
- return row(&key, &rest.join(" · "));
- }
- html! {}
-}
-
-/// One card row: a key in `code.key`, its value muted beside it.
-fn row(key: &str, value: &str) -> Markup {
- html! {
- div.card-row.signer-row {
- code.key { (key) }
- span.muted { (value) }
- }
- }
-}
-
-/// The textual form of a scalar peek: its string value, or its `Display`.
-fn scalar_text(peek: &Peek<'_, '_>) -> String {
- peek.as_str()
- .map_or_else(|| format!("{peek}"), str::to_owned)
-}
-
-/// A short label for a signer's key: its type and trailing comment, dropping the
-/// long base64 body that would not fit on the row.
-fn signer_label(key: &str) -> String {
- let mut parts = key.split_whitespace();
- let kind = parts.next().unwrap_or_default();
- let comment = parts.nth(1).unwrap_or_default();
- if comment.is_empty() {
- kind.to_owned()
- } else {
- format!("{kind} · {comment}")
- }
-}
-
-/// Whether `status` is still on its way to a terminal outcome — the check has
-/// no recording yet, but its run page has a live view worth linking to.
-pub(super) fn is_in_progress(status: Status) -> bool {
- matches!(status, Status::Queued | Status::Running)
-}
-
-/// A colored status word, shared by the checks-list row and the full
-/// recording page so the two agree on how a status reads: green for a pass,
-/// red for a failure, muted for anything still settling or skipped.
-fn status_badge(status: Status) -> Markup {
- let class = match status {
- Status::Pass => "status-pass",
- Status::Fail | Status::Error => "status-fail",
- Status::Running => "status-running",
- Status::Queued | Status::Skipped => "status-pending",
- };
- html! { span class=(class) { (status.to_string()) } }
-}
-
-/// One check's row on the "Checks on HEAD" card: the check's `name` and a
-/// status badge, both part of one link to `href` when there's a live view or
-/// a recording behind it, or "no run yet" when `outcome` is absent (just
-/// added, or its run has not landed).
-pub(super) fn check_list_row(name: &str, outcome: Option<&RunOutcome>, href: &str) -> Markup {
- html! {
- @match outcome {
- None => {
- code.key { (name) }
- span.muted { "no run yet" }
- }
- Some(outcome) if outcome.recording.is_some() || is_in_progress(outcome.status) => {
- a.row-link href=(href) {
- code.key { (name) }
- (status_badge(outcome.status))
- }
- }
- Some(outcome) => {
- code.key { (name) }
- (status_badge(outcome.status))
- }
- }
- }
-}
-
-/// The full recording-page body for a settled check: a summary line (status,
-/// exit code, duration) plus the terminal — a replay player, or a no-output
-/// notice when there is nothing worth replaying — and, when there's a
-/// recording, a link to download the raw asciicast.
-pub(super) fn check_result_view(outcome: &RunOutcome, download_href: &str) -> Markup {
- html! {
- div.check-summary {
- (status_badge(outcome.status))
- @if let Some(code) = outcome.exit_code {
- span.muted { "exit code " code { (code) } }
- }
- @if let Some(secs) = outcome.duration_secs {
- span.muted { (secs) "s" }
- }
- @if outcome.recording.is_some() {
- a.btn-quiet href=(download_href) download { "Download asciicast" }
- }
- }
- (settled_terminal(outcome))
- }
-}
-
-/// The terminal for a settled check: the replay player when the recording has
-/// visible output, or the exit-code notice when it doesn't (including when
-/// there's no recording at all, or acdc fails to render one) — acdc's replay
-/// player renders a bare empty box with no explanation otherwise.
-fn settled_terminal(outcome: &RunOutcome) -> Markup {
- let Some(recording) = &outcome.recording else {
- return no_output_notice(outcome);
- };
- if asciidoc::recording_has_no_output(recording) {
- return no_output_notice(outcome);
- }
- match asciidoc::render_recording(recording) {
- Some(player) => html! {
- style { (PreEscaped(asciidoc::TERMINAL_VIEW_CSS)) }
- (PreEscaped(player))
- },
- None => no_output_notice(outcome),
- }
-}
-
-/// The best-possible-UX fallback for a settled check with nothing to replay:
-/// its exit code when the command actually ran, or just its status when it
-/// didn't (a composite, or an infra failure before any command started). Boxed
-/// distinctly from a real terminal (dashed border, no fixed light background)
-/// so it reads as "nothing recorded" rather than as an empty transcript.
-fn no_output_notice(outcome: &RunOutcome) -> Markup {
- html! {
- div.terminal-empty {
- @match outcome.exit_code {
- Some(code) => { "Check finished with exit code " code { (code) } " without output." }
- None => { "This check produced no terminal output." }
- }
- }
- }
-}
-
-/// The live-terminal container's inner markup for one poll: the check's
-/// current screen, rendered as a static snapshot (see
-/// [`asciidoc::render_live`]), or a placeholder while output has yet to
-/// arrive.
-pub(super) fn live_fragment_body(recording: Option<String>) -> Markup {
- let rendered = recording
- .filter(|recording| !asciidoc::recording_has_no_output(recording))
- .and_then(|recording| asciidoc::render_live(&recording));
- match rendered {
- Some(player) => html! { (PreEscaped(player)) },
- None => html! { div.terminal-empty { "Waiting for output…" } },
- }
-}
crates/git-ents-server/src/web/style.css
@@ -1,409 +1,0 @@
-:root {
- --font-sans: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- --font-serif: "Lora", Georgia, "Times New Roman", serif;
- --font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace;
- --max-width: 78rem;
- --color-bg: #faf8f4;
- --color-surface: #fff;
- --color-text: #2a2518;
- --color-text-muted: #8a7e6a;
- --color-link: #b07d10;
- --color-link-hover: #96690a;
- --color-border: #ede9de;
- --color-code-bg: #f5f3eb;
- --color-accent: #b07d10;
- --color-accent-subtle: #b07d100f;
- --shadow-sm: 0 1px 3px #0000000d;
- --shadow-md: 0 4px 16px #0000000f;
- --radius-sm: 10px;
- --radius-pill: 100px;
- --s-comment: #9c8f74;
- --s-keyword: #9d0006;
- --s-func: #427b58;
- --s-type: #b57614;
- --s-string: #79740e;
- --s-const: #8f3f71;
- --s-op: #7c6f57;
- --s-prop: #076678;
- --diff-add: #4e9a0622;
- --diff-del: #cc241d22;
-}
-@media (prefers-color-scheme: dark) {
- :root {
- --color-bg: #171510;
- --color-surface: #211f17;
- --color-text: #ede8d8;
- --color-text-muted: #a89e88;
- --color-link: #d4a030;
- --color-link-hover: #e4b850;
- --color-border: #383324;
- --color-code-bg: #211f17;
- --color-accent: #d4a030;
- --color-accent-subtle: #d4a03012;
- --shadow-sm: 0 1px 3px #00000040;
- --shadow-md: 0 4px 16px #0000004d;
- --s-comment: #928374;
- --s-keyword: #fb4934;
- --s-func: #8ec07c;
- --s-type: #fabd2f;
- --s-string: #b8bb26;
- --s-const: #d3869b;
- --s-op: #a89984;
- --s-prop: #83a598;
- --diff-add: #b8bb2620;
- --diff-del: #fb493420;
- }
-}
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-html { font-size: 17px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
-body {
- font-family: var(--font-sans);
- background: var(--color-bg);
- color: var(--color-text);
- line-height: 1.7;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- background-image: radial-gradient(58rem 30rem at 50% -10rem, var(--color-accent-subtle), transparent 72%);
- background-attachment: fixed;
-}
-a { color: var(--color-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--color-link) 25%, transparent); text-underline-offset: 2px; transition: color .15s, text-decoration-color .15s; }
-a:hover { color: var(--color-link-hover); text-decoration-color: currentColor; }
-a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; }
-.icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; }
-
-/* Controls that are not wired up yet read as muted and non-interactive. */
-.stub { opacity: .5; cursor: not-allowed; }
-button:disabled, input:disabled, textarea:disabled, select:disabled { opacity: .5; cursor: not-allowed; }
-.btn-primary:disabled:hover { background: var(--color-accent); }
-.btn-danger-outline:disabled:hover { background: transparent; }
-
-.site-nav { position: sticky; top: 0; z-index: 100; background: color-mix(in srgb, var(--color-bg) 82%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--color-border); }
-.nav-inner { max-width: var(--max-width); margin: 0 auto; height: 58px; padding: 0 1.5rem; display: flex; align-items: center; gap: 1.25rem; }
-.nav-logo { display: inline-flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-weight: 700; font-size: 1.02rem; color: var(--color-text); letter-spacing: -.01em; text-decoration: none; white-space: nowrap; transition: color .15s; }
-.nav-logo .icon { color: var(--color-accent); width: 18px; height: 18px; }
-.nav-logo:hover { color: var(--color-accent); }
-.nav-search { flex: 1; max-width: 24rem; margin: 0 auto; position: relative; display: flex; align-items: center; }
-.nav-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); pointer-events: none; }
-.nav-search input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; transition: border-color .15s; }
-.nav-search input:focus { border-color: var(--color-accent); }
-.nav-search:has(input:disabled) { opacity: .55; }
-.nav-search:has(input:disabled) input { opacity: 1; }
-
-.content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; }
-
-.page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
-.page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
-.page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; display: inline-flex; align-items: center; gap: .55rem; }
-.page-title .icon { color: var(--color-accent); width: 20px; height: 20px; }
-.page-title a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.page-title a:hover { color: var(--color-accent); }
-.count { margin-left: auto; font-family: var(--font-mono); font-size: .8rem; color: var(--color-text-muted); }
-
-.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; display: inline-flex; align-items: center; gap: .3rem; }
-.branch .icon { width: 13px; height: 13px; }
-
-.repo-header { display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
-.repo-headline { flex: 1; min-width: 0; }
-.repo-path { font-family: var(--font-mono); font-size: 1.18rem; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; word-break: break-all; }
-.repo-path .icon { color: var(--color-accent); }
-.repo-path a { color: var(--color-text-muted); text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.repo-path a:hover { color: var(--color-accent); }
-.repo-path .here { color: var(--color-accent); font-weight: 600; }
-.repo-path .sep { color: var(--color-text-muted); opacity: .55; }
-.pill-public { font-family: var(--font-mono); font-size: .7rem; font-weight: 500; color: var(--color-text-muted); background: var(--color-code-bg); border-radius: var(--radius-pill); padding: .1rem .55rem; }
-.repo-desc { font-size: .98rem; color: var(--color-text); max-width: 40rem; margin-top: .65rem; }
-.topics { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .75rem; }
-.topic { font-family: var(--font-mono); font-size: .72rem; color: var(--color-text-muted); background: var(--color-accent-subtle); border-radius: var(--radius-pill); padding: .12rem .6rem; cursor: default; }
-.tabs { display: flex; gap: .15rem; border-bottom: 1px solid var(--color-border); margin-bottom: 1.75rem; overflow-x: auto; }
-.tab { display: inline-flex; align-items: center; gap: .4rem; padding: 10px 14px; font-size: .88rem; color: var(--color-text-muted); text-decoration: none; white-space: nowrap; position: relative; transition: color .15s; }
-.tab:hover { color: var(--color-text); }
-.tab.active { color: var(--color-text); font-weight: 600; }
-.tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--color-accent); }
-.tab-count { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .4rem; }
-
-.overview { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 34px; align-items: start; }
-.aside { position: sticky; top: 78px; display: flex; flex-direction: column; gap: 18px; min-width: 0; }
-.aside .card { margin-bottom: 0; }
-.aside .clone code { font-size: .76rem; padding: .6rem .8rem; }
-.aside-row { display: flex; align-items: center; gap: .5rem; padding: .55rem 1.1rem; font-size: .82rem; }
-.aside-row + .aside-row { border-top: 1px solid var(--color-border); }
-.aside-row .icon { color: var(--color-text-muted); flex-shrink: 0; }
-.aside-row .muted { color: var(--color-text-muted); }
-.aside-row .count { margin-left: 0; font-family: var(--font-mono); font-weight: 600; color: var(--color-accent); }
-.aside-row a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.lang-dot, .swatch { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
-.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
-.lang { padding: .8rem 1.1rem; }
-.lang-bar { display: flex; height: 8px; border-radius: var(--radius-pill); overflow: hidden; background: var(--color-code-bg); }
-.lang-bar span { display: block; height: 100%; }
-.lang-legend { list-style: none; display: flex; flex-direction: column; gap: .35rem; margin-top: .7rem; font-size: .78rem; }
-.lang-legend li { display: flex; align-items: center; gap: .45rem; }
-.lang-legend .pct { margin-left: auto; font-family: var(--font-mono); color: var(--color-text-muted); }
-.latest { font-family: var(--font-mono); font-size: .8rem; }
-.tag-pill { color: var(--color-accent); font-weight: 600; }
-
-@media (max-width: 860px) {
- .overview { grid-template-columns: minmax(0, 1fr); }
- .aside { position: static; }
-}
-
-.repo-list { list-style: none; }
-.repo-list li + li { border-top: 1px solid var(--color-border); }
-.repo-row { display: flex; align-items: center; gap: .85rem; padding: .9rem .75rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; transition: background .18s, transform .18s; }
-.repo-row:hover { text-decoration: none; transform: translateX(3px); }
-.repo-row:hover .repo-name { color: var(--color-accent); }
-.repo-row:hover .repo-arrow { opacity: 1; transform: translateX(0); }
-.repo-row .repo-icon { color: var(--color-accent); display: inline-flex; }
-.repo-name { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; flex: 1; min-width: 0; transition: color .18s; word-break: break-all; }
-.repo-badge { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .08rem .55rem; }
-.repo-arrow { color: var(--color-accent); display: inline-flex; opacity: 0; transform: translateX(-6px); transition: opacity .22s, transform .22s; }
-
-.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; overflow: hidden; }
-.card-header { display: flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); }
-.card-header .sha { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.card-header .sha:hover { color: var(--color-accent); }
-.card-row { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; font-family: var(--font-mono); font-size: .9rem; }
-.card-row + .card-row { border-top: 1px solid var(--color-border); }
-.card-row .icon { color: var(--color-text-muted); }
-.card-row.is-dir .icon { color: var(--color-accent); }
-
-.commit { padding: .85rem 1.1rem; }
-.commit + .commit { border-top: 1px solid var(--color-border); }
-.commit-subject { font-weight: 600; line-height: 1.45; }
-.commit-meta { font-size: .8rem; color: var(--color-text-muted); margin-top: .15rem; }
-.commit-meta .sha { font-family: var(--font-mono); background: var(--color-code-bg); padding: .08rem .4rem; border-radius: 5px; font-size: .76rem; margin-right: .5rem; }
-
-.clone { display: flex; align-items: stretch; }
-.clone code { flex: 1; font-family: var(--font-mono); font-size: .82rem; background: var(--color-code-bg); padding: .7rem 1rem; overflow-x: auto; white-space: pre; color: var(--color-text); }
-.copy-btn { font-family: var(--font-mono); font-size: .74rem; font-weight: 600; border: none; border-left: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text-muted); padding: 0 1rem; cursor: pointer; transition: color .15s, background .15s; }
-.copy-btn:hover { color: var(--color-accent); background: var(--color-accent-subtle); }
-
-.blankslate { text-align: center; padding: 3rem 1.5rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); }
-.blankslate h2 { font-family: var(--font-serif); font-size: 1.3rem; font-weight: 700; margin-bottom: .5rem; }
-.blankslate p { color: var(--color-text-muted); }
-.blankslate code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .15rem .45rem; border-radius: 5px; font-size: .85rem; }
-.btn { display: inline-flex; align-items: center; gap: .4rem; margin-top: 1.25rem; font-size: .88rem; font-weight: 600; color: var(--color-accent); text-decoration: none; padding: .45rem 1rem; border-radius: var(--radius-sm); border: 1px solid var(--color-border); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: border-color .15s, box-shadow .15s; }
-.btn:hover { text-decoration: none; border-color: var(--color-accent); box-shadow: var(--shadow-md); }
-
-.card-row a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); flex: 1; min-width: 0; word-break: break-all; }
-.card-row a:hover { color: var(--color-accent); }
-.card-row a.row-link { display: flex; align-items: center; gap: .65rem; }
-.card-row:has(.row-link):hover { background: var(--color-code-bg); }
-.commit-subject a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.commit-subject a:hover { color: var(--color-accent); }
-
-.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; }
-.crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.crumbs .sep { color: var(--color-text-muted); opacity: .55; }
-.crumbs .here { color: var(--color-text-muted); }
-
-.files { display: grid; grid-template-columns: 17rem minmax(0, 1fr); min-height: 30rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; }
-.tree-pane { background: var(--color-bg); border-right: 1px solid var(--color-border); overflow: auto; padding-bottom: .5rem; }
-.tree-head { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem .9rem; border-bottom: 1px solid var(--color-border); display: flex; align-items: center; gap: .4rem; }
-.tree-head .icon { width: 13px; height: 13px; }
-.tree-row { display: flex; align-items: center; gap: .3rem; font-family: var(--font-mono); font-size: .79rem; padding: 4px 8px; text-decoration: none; color: var(--color-text); white-space: nowrap; }
-.tree-row:hover { text-decoration: none; background: var(--color-code-bg); }
-.tree-row.sel { background: var(--color-accent-subtle); color: var(--color-accent); font-weight: 600; }
-.tree-row .chev { width: 12px; height: 12px; flex-shrink: 0; color: var(--color-text-muted); transition: transform .15s; }
-.tree-row .chev.open { transform: rotate(90deg); }
-.tree-row .ic-folder { display: inline-flex; color: var(--color-accent); }
-.tree-row.sel .ic-folder { color: var(--color-accent); }
-.tree-row .ic-file { display: inline-flex; color: var(--color-text-muted); }
-.tree-row span:last-child { overflow: hidden; text-overflow: ellipsis; }
-.blob-pane { display: flex; flex-direction: column; min-width: 0; }
-.blob-head { display: flex; align-items: center; gap: .5rem; background: var(--color-code-bg); border-bottom: 1px solid var(--color-border); padding: .5rem 1rem; font-family: var(--font-mono); font-size: .78rem; }
-.blob-head .meta { margin-left: auto; display: flex; align-items: center; gap: .8rem; color: var(--color-text-muted); }
-.blob-head .copy-btn { border: 1px solid var(--color-border); border-radius: 6px; padding: .1rem .55rem; background: var(--color-surface); }
-.blob-pane .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; overflow: auto; flex: 1; }
-.files-empty { margin: auto; padding: 3rem; text-align: center; color: var(--color-text-muted); }
-.files-empty .icon { width: 28px; height: 28px; opacity: .5; margin-bottom: .5rem; }
-
-@media (max-width: 700px) {
- .files { grid-template-columns: 1fr; min-height: 0; }
- .tree-pane { border-right: 0; border-bottom: 1px solid var(--color-border); max-height: 50vh; }
-}
-
-.blob { display: grid; grid-template-columns: auto minmax(0, 1fr); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; }
-.blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 0; }
-.blob pre.blob-nums { text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding-left: 1ch; padding-right: 1ch; user-select: none; -webkit-user-select: none; }
-.blob-nums a { display: block; color: inherit; text-decoration: none; }
-.blob-nums a:hover { color: var(--color-accent); }
-.blob-nums a:target { color: var(--color-accent); font-weight: 700; }
-.blob-code { overflow-x: auto; min-width: 0; }
-.blob-code code { display: block; font-family: inherit; padding: 0 1.25rem; white-space: pre; color: var(--color-text); }
-.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
-
-.view-toggle { display: flex; justify-content: flex-end; margin-bottom: .6rem; }
-.view-toggle a.chip { cursor: pointer; text-decoration: none; }
-.view-toggle a.chip:hover { color: var(--color-accent); border-color: var(--color-accent); }
-
-.file-comments { margin-bottom: 1.5rem; }
-.comment-row { padding: .8rem 1.1rem; border-bottom: 1px solid var(--color-border); }
-.comment-row:last-child { border-bottom: 0; }
-.comment-meta { display: flex; align-items: center; gap: .6rem; font-size: .8rem; color: var(--color-text-muted); margin-bottom: .35rem; }
-.comment-meta .author { font-weight: 600; color: var(--color-text); }
-.comment-meta a.chip { cursor: pointer; }
-.comment-body { margin: 0; white-space: pre-wrap; font-size: .92rem; }
-
-.code .keyword, .code .macro, .code .tag { color: var(--s-keyword); }
-.code .function, .code .constructor { color: var(--s-func); }
-.code .type { color: var(--s-type); }
-.code .string { color: var(--s-string); }
-.code .number, .code .constant, .code .label { color: var(--s-const); }
-.code .comment { color: var(--s-comment); font-style: italic; }
-.code .operator, .code .punctuation { color: var(--s-op); }
-.code .property, .code .attribute { color: var(--s-prop); }
-.code .title { color: var(--s-keyword); font-weight: 700; }
-.code .strong { font-weight: 700; }
-.code .emphasis { font-style: italic; }
-.code .link, .code .url, .code .reference { color: var(--s-prop); text-decoration: underline; }
-.code .markup { color: var(--s-func); }
-
-.diff { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow-x: auto; margin-bottom: 1.5rem; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; padding: .6rem 0; }
-.diff .ln { display: block; padding: 0 1rem; white-space: pre; }
-.diff .add { background: var(--diff-add); }
-.diff .del { background: var(--diff-del); }
-.diff .hunk { color: var(--s-prop); background: var(--color-code-bg); }
-.diff .meta { color: var(--color-text-muted); }
-.diff .file { color: var(--color-text); font-weight: 600; background: var(--color-code-bg); padding-top: .3rem; padding-bottom: .3rem; }
-
-.commit-msg { font-family: var(--font-mono); font-size: .9rem; white-space: pre-wrap; word-break: break-word; }
-
-.adoc-body { padding: 40px 48px 52px; max-width: 44rem; overflow-wrap: break-word; }
-.adoc-body > :first-child { margin-top: 0; }
-.adoc-body h1, .adoc-body h2, .adoc-body h3, .adoc-body h4 { font-family: var(--font-serif); font-weight: 700; letter-spacing: -.01em; line-height: 1.25; margin: 1.8rem 0 .9rem; }
-.adoc-body h1 { font-size: 2.4rem; letter-spacing: -.02em; }
-.adoc-body .doc-subtitle { font-family: var(--font-serif); font-style: italic; font-size: 1.18rem; color: var(--color-text-muted); margin: -.4rem 0 1.2rem; }
-.adoc-body h2 { font-size: 1.4rem; font-weight: 600; position: relative; padding-bottom: .55rem; }
-.adoc-body h2::after { content: ""; position: absolute; left: 0; bottom: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
-.adoc-body h3 { font-size: 1.15rem; font-weight: 600; }
-.adoc-body p, .adoc-body ul, .adoc-body ol { margin: 0 0 1rem; }
-.adoc-body ul, .adoc-body ol { padding-left: 1.4rem; }
-.adoc-body li { margin: .25rem 0; }
-.adoc-body a { font-weight: 500; }
-.adoc-body code, .adoc-body .literal { font-family: var(--font-mono); font-size: .86em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; }
-.adoc-body pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 1rem 1.2rem; overflow-x: auto; margin: 0 0 1rem; }
-.adoc-body pre code { background: none; padding: 0; font-size: inherit; }
-.adoc-body blockquote { border-left: 3px solid var(--color-accent); padding: .2rem 0 .2rem 1.1rem; margin: 0 0 1rem; color: var(--color-text-muted); }
-.adoc-body table { border-collapse: collapse; margin: 0 0 1rem; font-size: .92rem; }
-.adoc-body th, .adoc-body td { border: 1px solid var(--color-border); padding: .4rem .7rem; text-align: left; }
-.adoc-body th { background: var(--color-code-bg); font-weight: 600; }
-.adoc-body .title { font-weight: 600; color: var(--color-text-muted); font-size: .9rem; margin-bottom: .3rem; }
-.adoc-body img { max-width: 100%; height: auto; }
-.adoc-body hr { border: none; border-top: 1px solid var(--color-border); margin: 1.8rem 0; }
-
-.timeline { position: relative; padding-left: 30px; }
-.timeline::before { content: ""; position: absolute; left: 5px; top: 0; bottom: 0; width: 2px; background: var(--color-border); }
-.release { position: relative; margin-bottom: 1.5rem; }
-.release::before { content: ""; position: absolute; left: -30px; top: 16px; width: 12px; height: 12px; border-radius: var(--radius-pill); background: var(--color-surface); border: 2px solid var(--color-border); box-shadow: 0 0 0 4px var(--color-bg); }
-.release.latest::before { background: var(--color-accent); border-color: var(--color-accent); }
-.release-head { display: flex; align-items: center; gap: .55rem; padding: .85rem 1.1rem; border-bottom: 1px solid var(--color-border); flex-wrap: wrap; }
-.release-head .icon { color: var(--color-accent); }
-.release-tag { font-family: var(--font-mono); font-size: .9rem; font-weight: 600; color: var(--color-accent); }
-.release-name { font-family: var(--font-serif); font-size: 1.05rem; font-weight: 600; }
-.release-date { margin-left: auto; font-size: .8rem; color: var(--color-text-muted); }
-.badge-latest { font-family: var(--font-mono); font-size: .66rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--s-func); background: var(--color-code-bg); border-radius: var(--radius-pill); padding: .05rem .5rem; }
-.release-body { padding: 1rem 1.1rem; }
-.release-body p { white-space: pre-wrap; font-size: .9rem; }
-.release-body .muted { color: var(--color-text-muted); }
-.release-foot { display: flex; align-items: center; padding: .7rem 1.1rem; border-top: 1px solid var(--color-border); font-family: var(--font-mono); font-size: .74rem; }
-.release-foot .sha { margin-left: auto; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: .35rem; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.release-foot .sha:hover { color: var(--color-accent); }
-
-.notice { font-size: .9rem; font-weight: 600; padding: .6rem 1.1rem; border-radius: var(--radius-sm); margin-bottom: 1.25rem; }
-.notice-success { color: var(--s-func); background: color-mix(in srgb, var(--s-func) 12%, transparent); border: 1px solid color-mix(in srgb, var(--s-func) 30%, transparent); }
-
-.shell-note { color: var(--color-text-muted); font-size: .95rem; max-width: 44rem; margin-bottom: 1.5rem; }
-.card .shell-note { padding: .7rem 1.1rem; margin-bottom: 0; }
-.shell-note code { font-family: var(--font-mono); font-size: .85em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; }
-.card-row.muted { color: var(--color-text-muted); }
-.card-row.muted code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; }
-.signer-row .key { font-family: var(--font-mono); font-size: .82rem; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: 5px; padding: .25rem .55rem; }
-.issue-row { gap: .55rem; }
-.issue-row .issue-title { flex: 1; min-width: 0; font-family: var(--font-sans); color: var(--color-text); }
-
-.checks-grid { display: grid; grid-template-columns: 20rem minmax(0, 1fr); gap: 20px; align-items: start; }
-.checks-grid .card { margin-bottom: 0; }
-.checks-grid .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; }
-@media (max-width: 800px) { .checks-grid { grid-template-columns: 1fr; } }
-
-.issues-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; }
-.issues-head::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
-.issues-head .page-title { white-space: nowrap; }
-.btn-primary { margin-left: auto; display: inline-flex; align-items: center; gap: .4rem; font-family: var(--font-sans); font-size: .85rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem .9rem; cursor: pointer; transition: background .15s; }
-.btn-primary:hover { background: var(--color-link-hover); }
-.btn-primary .icon { color: currentColor; }
-.filter-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
-.filter-search { flex: 1; min-width: 14rem; position: relative; display: flex; align-items: center; }
-.filter-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); }
-.filter-search input { width: 100%; font-family: var(--font-sans); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; }
-.filter-search input:focus { border-color: var(--color-accent); }
-.filter-search:has(input:disabled) { opacity: .55; }
-.filter-search:has(input:disabled) input { opacity: 1; }
-.chip { font-family: var(--font-mono); font-size: .76rem; color: var(--color-text-muted); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .2rem .7rem; cursor: default; }
-.chip.active { color: var(--color-bg); background: var(--color-accent); border-color: var(--color-accent); }
-.card-header.subtabs { display: flex; gap: 1.25rem; text-transform: none; letter-spacing: 0; }
-.subtab { display: inline-flex; align-items: center; gap: .35rem; color: var(--color-text-muted); cursor: default; }
-.subtab.active { color: var(--color-text); font-weight: 600; }
-.subtab .icon { width: 13px; height: 13px; }
-.subtab.active .icon { color: var(--s-func); }
-
-.settings { max-width: 50rem; margin: 0 auto; }
-.setting-label { flex-shrink: 0; min-width: 11rem; font-weight: 600; font-family: var(--font-sans); }
-.feature-row { display: flex; align-items: center; gap: 1rem; padding: .85rem 1.1rem; }
-.feature-row + .feature-row { border-top: 1px solid var(--color-border); }
-.feature-row > div:first-child { flex: 1; }
-.feature-row p { font-size: .82rem; margin: .1rem 0 0; }
-.feature-status { flex-shrink: 0; font-family: var(--font-mono); font-size: .76rem; color: var(--color-text-muted); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .2rem .7rem; }
-.feature-status.on { color: var(--color-bg); background: var(--color-accent); border-color: var(--color-accent); }
-
-.status-pass { color: var(--s-func); font-weight: 600; }
-.status-fail { color: var(--s-keyword); font-weight: 600; }
-.status-running { color: var(--color-accent); font-weight: 600; }
-.status-pending { color: var(--color-text-muted); }
-
-.run-results { display: flex; flex-wrap: wrap; gap: .3rem .9rem; flex: 1; min-width: 0; }
-.run-results .run-result { flex: none; color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: .3rem; }
-.run-results a.run-result { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
-.run-results a.run-result:hover { color: var(--color-accent); }
-.check-summary { display: flex; align-items: center; gap: .85rem; margin-bottom: 1.25rem; font-family: var(--font-mono); font-size: .9rem; }
-.check-summary .btn-quiet { margin-left: auto; }
-.terminal-empty { color: var(--color-text-muted); font-size: .9rem; background: var(--color-code-bg); border: 1px dashed var(--color-border); border-radius: var(--radius-sm); padding: 1rem 1.2rem; }
-
-.site-footer { border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: .8rem; margin-top: auto; }
-.footer-inner { max-width: var(--max-width); margin: 0 auto; padding: 2rem 1.5rem; text-align: center; }
-.footer-inner a { color: var(--color-text-muted); text-decoration: none; }
-.footer-inner a:hover { color: var(--color-accent); }
-
-@media (max-width: 640px) {
- html { font-size: 16px; }
- .content { padding: 1.5rem 1.25rem 2.5rem; }
- .nav-inner { gap: .75rem; padding: 0 1.25rem; }
- .nav-search { max-width: none; }
- .page-title { font-size: 1.3rem; }
- .repo-path { font-size: 1.05rem; }
- .adoc-body { padding: 1.75rem 1.5rem 2rem; }
- .adoc-body h1 { font-size: 1.9rem; }
- .adoc-body h2 { font-size: 1.25rem; }
- .issues-head, .filter-row { flex-wrap: wrap; }
- .btn-primary { margin-left: 0; }
-}
-
-/* Authenticated browser edits: the account strip, sign-in form, and inline edits. */
-.account-strip { display: flex; align-items: center; justify-content: flex-end; gap: .75rem; margin-bottom: 1rem; font-size: .85rem; }
-.account-strip form { margin: 0; }
-.btn-quiet { margin-top: 0; padding: .3rem .75rem; font-size: .82rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); text-decoration: none; cursor: pointer; }
-.btn-quiet:hover { border-color: var(--color-accent); color: var(--color-accent); }
-.auth-banner { font-size: .9rem; max-width: 44rem; margin-bottom: 1.5rem; padding: .7rem 1.1rem; border-radius: var(--radius-sm); background: var(--color-code-bg); border: 1px solid var(--color-border); }
-.auth-banner-active { color: var(--color-accent); background: var(--color-accent-subtle); border-color: color-mix(in srgb, var(--color-accent) 30%, transparent); }
-.edit-form { display: flex; flex-direction: column; gap: .4rem; max-width: 40rem; }
-.edit-form label { font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); }
-.edit-form input, .edit-form textarea, .inline-edit input { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; }
-.edit-form input:focus, .edit-form textarea:focus, .inline-edit input:focus { border-color: var(--color-accent); }
-.edit-form textarea { resize: vertical; }
-.edit-form .btn { align-self: flex-start; }
-.inline-edit { display: flex; gap: .5rem; flex: 1; align-items: center; }
-.inline-edit input { flex: 1; min-width: 0; }
-.inline-edit .btn { margin-top: 0; padding: .35rem .85rem; }
-.signin-cmd { font-family: var(--font-mono); font-size: .82rem; color: var(--color-text); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .7rem .9rem; margin: 0 0 1rem; white-space: pre-wrap; word-break: break-all; }
crates/git-ents-server/src/web/write.rs
@@ -1,693 +1,0 @@
-//! Authenticated browser writes.
-//!
-//! Signing in never surrenders a private key. The server issues a one-time
-//! challenge; the member signs it locally with their web key and pastes back the
-//! signature and their public key. The server verifies that signature against
-//! the pasted key, which proves the browser controls it — the same proof a CLI
-//! push gives, without the key ever leaving the member's machine.
-//!
-//! An edit is then landed as a real `git push --signed` onto `refs/meta/config`,
-//! signed with the *server's own* member key, so it passes the very same
-//! `pre-receive` gate a CLI push does. The commit's author is the signed-in
-//! human (resolved from their key's membership); the committer is the server.
-//! Nothing secret to the member is ever held or persisted: a session keeps only
-//! their public key.
-
-use std::collections::HashMap;
-use std::io::Write as _;
-use std::path::Path;
-use std::process::{Command, Stdio};
-use std::sync::{Arc, Mutex};
-use std::time::{Duration, Instant};
-
-/// The cookie that carries a session token.
-pub(super) const COOKIE: &str = "ents_session";
-
-/// The SSHSIG namespace a sign-in signature is made under — distinct from git's
-/// own `git` namespace, so a login signature can never double as a push and vice
-/// versa.
-pub(super) const LOGIN_NAMESPACE: &str = "git.ents.cloud";
-
-/// How long an issued sign-in challenge stays valid.
-const CHALLENGE_TTL: Duration = Duration::from_secs(600);
-
-/// In-memory session table, shared by every handler.
-pub(crate) type Sessions = Arc<Mutex<HashMap<String, Session>>>;
-
-/// Outstanding sign-in challenges and when each was issued; consumed once.
-pub(crate) type Challenges = Arc<Mutex<HashMap<String, Instant>>>;
-
-/// One browser session. It holds only the member's *public* key — enough to
-/// authorize per repository — plus a display label and a CSRF token.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.session)
-pub(crate) struct Session {
- /// The member's public key line (`type base64`), matched against members.
- public_key: String,
- /// A human label for the key — its comment, or its type.
- label: String,
- /// A per-session token that state-changing form posts must echo back, so a
- /// cross-site request (which cannot read it) cannot act as the user.
- csrf: String,
-}
-
-/// A cheap, cloneable view of a session for rendering and authorization.
-#[derive(Clone)]
-pub(super) struct SessionSnapshot {
- pub(super) label: String,
- pub(super) public_key: String,
- pub(super) csrf: String,
-}
-
-/// The fields a settings edit may change on `refs/meta/config`.
-pub(super) struct ConfigEdit {
- pub(super) description: String,
- pub(super) homepage: String,
- pub(super) topics: Vec<String>,
-}
-
-/// The fields of a new code comment posted from a file view.
-pub(super) struct CommentEdit {
- pub(super) path: String,
- pub(super) lines: Option<git_anchor::LineRange>,
- pub(super) body: String,
-}
-
-/// Create an empty session table.
-pub(crate) fn new_sessions() -> Sessions {
- Arc::new(Mutex::new(HashMap::new()))
-}
-
-/// Create an empty challenge table.
-pub(crate) fn new_challenges() -> Challenges {
- Arc::new(Mutex::new(HashMap::new()))
-}
-
-/// Issue a fresh one-time sign-in challenge, returning the nonce to sign.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge)
-pub(super) fn issue_challenge(challenges: &Challenges) -> Result<String, String> {
- let nonce = random_token()?;
- let mut table = challenges
- .lock()
- .map_err(|_poisoned| "challenge store unavailable".to_owned())?;
- let now = Instant::now();
- table.retain(|_nonce, issued| now.duration_since(*issued) < CHALLENGE_TTL);
- table.insert(nonce.clone(), now);
- Ok(nonce)
-}
-
-/// Consume `nonce`, returning whether it was a live, unexpired challenge.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge)
-fn take_challenge(challenges: &Challenges, nonce: &str) -> bool {
- let Ok(mut table) = challenges.lock() else {
- return false;
- };
- match table.remove(nonce) {
- Some(issued) => Instant::now().duration_since(issued) < CHALLENGE_TTL,
- None => false,
- }
-}
-
-/// The session a `Cookie` header points at, as a snapshot, if any.
-pub(super) fn snapshot(sessions: &Sessions, cookie: Option<&str>) -> Option<SessionSnapshot> {
- let token = token(cookie?)?;
- let table = sessions.lock().ok()?;
- let session = table.get(&token)?;
- Some(SessionSnapshot {
- label: session.label.clone(),
- public_key: session.public_key.clone(),
- csrf: session.csrf.clone(),
- })
-}
-
-/// Complete a sign-in: verify the pasted `signature` over the issued `nonce`
-/// against the pasted `public_key`, and on success open a session and return its
-/// token. Holding a session grants nothing on its own — an edit is authorized
-/// per repository against the live member list.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge, web.auth.session)
-pub(super) fn login(
- sessions: &Sessions,
- challenges: &Challenges,
- body: &[u8],
-) -> Result<String, String> {
- let fields = form(body);
- let public_key = trimmed(&fields, "public_key");
- let signature = trimmed(&fields, "signature");
- let nonce = trimmed(&fields, "nonce");
- if public_key.is_empty() || signature.is_empty() {
- return Err("paste your public key and the signature".to_owned());
- }
- if !take_challenge(challenges, &nonce) {
- return Err("your sign-in challenge expired; reload and try again".to_owned());
- }
- if !verify_login_signature(&public_key, &nonce, &signature)? {
- return Err("the signature did not match that public key".to_owned());
- }
-
- let label = key_comment(&public_key).unwrap_or_else(|| key_type(&public_key));
- let token = random_token()?;
- let csrf = random_token()?;
- let mut table = sessions
- .lock()
- .map_err(|_poisoned| "session store unavailable".to_owned())?;
- table.insert(
- token.clone(),
- Session {
- public_key: normalize_key(&public_key),
- label,
- csrf,
- },
- );
- Ok(token)
-}
-
-/// Whether `cookie`'s session exists and its CSRF token matches `token`.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.session)
-pub(super) fn csrf_ok(sessions: &Sessions, cookie: Option<&str>, token: &str) -> bool {
- let Some(session_token) = cookie.and_then(self::token) else {
- return false;
- };
- sessions
- .lock()
- .ok()
- .and_then(|table| table.get(&session_token).map(|s| s.csrf == token))
- .unwrap_or(false)
-}
-
-/// Drop the session a `Cookie` header points at, if any.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.session)
-pub(super) fn logout(sessions: &Sessions, cookie: Option<&str>) {
- let Some(token) = cookie.and_then(token) else {
- return;
- };
- if let Ok(mut table) = sessions.lock() {
- table.remove(&token);
- }
-}
-
-/// Refuse `username` unless they are [`Provenance::AdminRegistered`]: a
-/// self-attested web member gets limited trust and may not edit settings
-/// (outside the allowed set of issues/comments) until an admin promotes them.
-/// `pre_receive` cannot enforce this — it is purely key-based, and a
-/// self-attested member typically has no push key to gate — so the web write
-/// path is the enforcement point.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.edit)
-fn require_admin_registered(store: &git_store::Store, username: &str) -> Result<(), String> {
- use git_member::members::Provenance;
- let member = git_member::members::load_with(store, username)
- .map_err(|e| format!("could not read member: {e}"))?
- .ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
- match member.provenance {
- Provenance::AdminRegistered => Ok(()),
- Provenance::SelfAttestedWeb => Err(
- "self-attested members may only edit issues and comments until an admin promotes them"
- .to_owned(),
- ),
- }
-}
-
-/// Land a configuration change: stage it on a throwaway ref authored by the
-/// signed-in member, then push it onto `refs/meta/config` signed with the
-/// server's key, through the `pre-receive` gate. Returns `Ok` only when the gate
-/// accepts the push.
-///
-/// `seed`, `hooks`, and `signing_key` are the server's signed-push nonce seed,
-/// hooks directory, and own member key; all are required, so a web edit is never
-/// a way around a server that is not enforcing the gate.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.edit)
-pub(super) fn edit_config(
- sessions: &Sessions,
- cookie: Option<&str>,
- repo: &Path,
- edit: &ConfigEdit,
- seed: &str,
- hooks: &Path,
- signing_key: &Path,
-) -> Result<(), String> {
- let public_key = session_public_key(sessions, cookie)
- .ok_or_else(|| "sign in to edit settings".to_owned())?;
-
- let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?;
- let username = member_for_public_key_with(&store, &public_key)
- .ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
- require_admin_registered(&store, &username)?;
-
- let mut config = git_ents_core::config::load_with(&store)
- .map_err(|e| format!("could not read config: {e}"))?;
- config.description = edit.description.clone();
- config.homepage = edit.homepage.clone();
- config.topics = edit.topics.clone();
-
- signed_edit(
- repo,
- git_ents_core::config::CONFIG_REF,
- &config,
- "Update configuration",
- &username,
- signing_key,
- seed,
- hooks,
- )
-}
-
-/// Land a new code comment on `refs/meta/comments/<id>`, anchored to `HEAD`'s
-/// blob at the commented path. Any signed-in member may comment — including a
-/// self-attested web member, whose allowed writes are exactly issues and
-/// comments — so there is no [`require_admin_registered`] gate here.
-///
-/// ## Requirements
-///
-/// @relation(web.comments)
-pub(super) fn add_comment(
- sessions: &Sessions,
- cookie: Option<&str>,
- repo: &Path,
- edit: &CommentEdit,
- seed: &str,
- hooks: &Path,
- signing_key: &Path,
-) -> Result<(), String> {
- let public_key =
- session_public_key(sessions, cookie).ok_or_else(|| "sign in to comment".to_owned())?;
- let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?;
- let username = member_for_public_key_with(&store, &public_key)
- .ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
-
- let anchor = git_anchor::capture(repo, "HEAD", &edit.path, edit.lines)
- .map_err(|e| format!("could not anchor the comment: {e}"))?;
- let comment = git_comment::Comment {
- body: edit.body.clone(),
- anchor,
- issue: None,
- reply_to: None,
- };
- let id = git_comment::new_id(None, &comment)
- .map_err(|e| format!("could not derive the comment id: {e}"))?;
- let target = format!("{}/{id}", git_comment::COMMENTS_NS);
- signed_edit(
- repo,
- &target,
- &comment,
- "Add comment",
- &username,
- signing_key,
- seed,
- hooks,
- )
-}
-
-/// Parse an optional `<start>[:<end>]` line-range form field; empty means a
-/// whole-file comment.
-pub(super) fn parse_lines(field: &str) -> Result<Option<git_anchor::LineRange>, String> {
- let field = field.trim();
- if field.is_empty() {
- return Ok(None);
- }
- let (start, end) = field.split_once(':').unwrap_or((field, field));
- let parse = |number: &str| {
- number
- .trim()
- .parse::<u64>()
- .map_err(|_error| format!("invalid line number {number:?}"))
- };
- Ok(Some(git_anchor::LineRange {
- start: parse(start)?,
- end: parse(end)?,
- }))
-}
-
-/// The signed-in session's public key, when `cookie` names a live session.
-fn session_public_key(sessions: &Sessions, cookie: Option<&str>) -> Option<String> {
- let token = cookie.and_then(token)?;
- let table = sessions.lock().ok()?;
- Some(table.get(&token)?.public_key.clone())
-}
-
-/// Land `value` onto `target_ref` as a real `git push --signed`, authored by
-/// `username` and signed with the server's own `signing_key`, through the
-/// same `pre-receive` gate a CLI push traverses — the one landing operation
-/// every authenticated browser write shares, whatever meta-ref it targets.
-///
-/// The contract: `value` is built on a fresh ref staged at `target_ref`'s
-/// current tip (so the push is a clean fast-forward), the staging ref is
-/// *always* deleted before returning — whether or not the push was accepted,
-/// so a rejected edit never leaves a zombie ref behind — and the commit that
-/// lands is authored by `username` while the server is the committer.
-// @relation(web.auth.edit)
-#[expect(
- clippy::too_many_arguments,
- reason = "the server identity a signed edit requires"
-)]
-fn signed_edit<T: for<'a> facet::Facet<'a>>(
- repo: &Path,
- target_ref: &str,
- value: &T,
- message: &str,
- username: &str,
- signing_key: &Path,
- seed: &str,
- hooks: &Path,
-) -> Result<(), String> {
- let staging = format!("refs/web-staging/{}", random_token()?);
- let result = stage_and_push(
- repo,
- &staging,
- target_ref,
- value,
- message,
- username,
- signing_key,
- seed,
- hooks,
- );
- // Clean up the staging ref whether or not the push was accepted.
- let _cleanup = git(repo, &["update-ref", "-d", &staging]);
- result
-}
-
-/// Point `staging` at `target_ref`'s current tip, build the new commit on it
-/// authored by `username`, then push it signed with the server's key onto
-/// `target_ref`.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.edit)
-#[expect(clippy::too_many_arguments, reason = "internal step of signed_edit")]
-fn stage_and_push<T: for<'a> facet::Facet<'a>>(
- repo: &Path,
- staging: &str,
- target_ref: &str,
- value: &T,
- message: &str,
- username: &str,
- signing_key: &Path,
- seed: &str,
- hooks: &Path,
-) -> Result<(), String> {
- if let Some(tip) = rev_parse(repo, target_ref) {
- git(repo, &["update-ref", staging, &tip])
- .map_err(|e| format!("could not stage the edit: {e}"))?;
- }
- let email = format!("{username}@web");
- git_store::Store::open(repo)
- .map_err(|e| format!("could not open store: {e}"))?
- .store_authored(staging, value, message, (username, &email))
- .map_err(|e| format!("could not build the edit: {e}"))?;
-
- let signer = signing_key
- .to_str()
- .ok_or_else(|| "signing key path is not UTF-8".to_owned())?;
- let hooks = hooks
- .to_str()
- .ok_or_else(|| "hooks path is not UTF-8".to_owned())?;
- let receive_pack = format!(
- "git -c receive.certNonceSeed={seed} -c receive.certNonceSlop=60 -c core.hooksPath={hooks} receive-pack"
- );
- let url = format!("file://{}", repo.display());
- let refspec = format!("{staging}:{target_ref}");
-
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["-c", "gpg.format=ssh"])
- .arg("-c")
- .arg(format!("user.signingkey={signer}"))
- .args([
- "-c",
- "user.name=git-ents-web",
- "-c",
- "user.email=web@git-ents",
- ])
- .arg("push")
- .arg("--signed")
- .arg(format!("--receive-pack={receive_pack}"))
- .arg(&url)
- .arg(&refspec)
- .stdin(Stdio::null())
- .output()
- .map_err(|e| format!("could not run git push: {e}"))?;
- if output.status.success() {
- Ok(())
- } else {
- Err(push_error(&output.stderr))
- }
-}
-
-/// Verify an SSHSIG `signature` over `nonce` was made by `public_key` under the
-/// login namespace, using `ssh-keygen -Y verify` against a one-key allowed
-/// signers file.
-///
-/// ## Requirements
-///
-/// @relation(web.auth.challenge)
-fn verify_login_signature(public_key: &str, nonce: &str, signature: &str) -> Result<bool, String> {
- let dir = tempfile::tempdir().map_err(|e| format!("could not create temp dir: {e}"))?;
- let allowed = dir.path().join("allowed_signers");
- let sig = dir.path().join("nonce.sig");
- write_file(
- &allowed,
- format!(
- "* namespaces=\"{LOGIN_NAMESPACE}\" {}\n",
- normalize_key(public_key)
- )
- .as_bytes(),
- )?;
- write_file(&sig, signature.as_bytes())?;
-
- let mut child = Command::new("ssh-keygen")
- .args(["-Y", "verify", "-n", LOGIN_NAMESPACE, "-I", "web", "-f"])
- .arg(&allowed)
- .arg("-s")
- .arg(&sig)
- .stdin(Stdio::piped())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()
- .map_err(|e| format!("could not run ssh-keygen: {e}"))?;
- if let Some(mut stdin) = child.stdin.take() {
- stdin
- .write_all(nonce.as_bytes())
- .map_err(|e| format!("could not hand the challenge to ssh-keygen: {e}"))?;
- }
- Ok(child
- .wait()
- .map_err(|e| format!("ssh-keygen did not complete: {e}"))?
- .success())
-}
-
-/// The username of the member whose web key matches `public_key`, if any, from
-/// an already-open `store`. The match is on the key type and body, ignoring
-/// any trailing comment.
-///
-/// O(m×k): loads every member and scans each one's keys. Acceptable at
-/// current scale; a batch path exists on the other axis
-/// (`members::load_all_indexed`, principal → member) but this lookup goes the
-/// other way (key → member), which would need its own index — deferred until
-/// measured, since a member legitimately holds more than one key, ruling out
-/// a simple bi-map.
-pub(super) fn member_for_public_key_with(
- store: &git_store::Store,
- public_key: &str,
-) -> Option<String> {
- let wanted = normalize_key(public_key);
- let members = git_member::members::load_all_with(store).ok()?;
- members.into_iter().find_map(|member| {
- member
- .keys()
- .iter()
- .any(|(_fingerprint, key)| normalize_key(key) == wanted)
- .then(|| member.principal.clone())
- })
-}
-
-/// The username of the member whose web key matches `public_key`, if any. See
-/// [`member_for_public_key_with`].
-pub(super) fn member_for_public_key(repo: &Path, public_key: &str) -> Option<String> {
- member_for_public_key_with(&git_store::Store::open(repo).ok()?, public_key)
-}
-
-/// A public key reduced to its type and body, dropping the comment so two lines
-/// for the same key compare equal.
-fn normalize_key(line: &str) -> String {
- let mut parts = line.split_whitespace();
- let kind = parts.next().unwrap_or_default();
- let body = parts.next().unwrap_or_default();
- format!("{kind} {body}")
-}
-
-/// The key's type word, used as a fallback label.
-fn key_type(public_key: &str) -> String {
- public_key
- .split_whitespace()
- .next()
- .unwrap_or("key")
- .to_owned()
-}
-
-/// The key's trailing comment, if it carries one.
-fn key_comment(public_key: &str) -> Option<String> {
- public_key
- .split_whitespace()
- .nth(2)
- .map(str::to_owned)
- .filter(|comment| !comment.is_empty())
-}
-
-fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
- std::fs::write(path, bytes).map_err(|e| format!("could not write {}: {e}", path.display()))
-}
-
-/// The pre-receive rejection reason from git's stderr, or a generic message.
-fn push_error(stderr: &[u8]) -> String {
- let text = String::from_utf8_lossy(stderr);
- text.lines()
- .find_map(|line| line.trim().strip_prefix("remote: error: "))
- .or_else(|| {
- text.lines()
- .find_map(|line| line.trim().strip_prefix("remote: "))
- .filter(|l| !l.is_empty())
- })
- .map(str::to_owned)
- .unwrap_or_else(|| "the push was rejected".to_owned())
-}
-
-/// The committed tip of `refname`, or `None` when the ref is absent.
-fn rev_parse(repo: &Path, refname: &str) -> Option<String> {
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["rev-parse", "--verify", "--quiet", refname])
- .stdin(Stdio::null())
- .output()
- .ok()?;
- let oid = String::from_utf8_lossy(&output.stdout).trim().to_owned();
- (output.status.success() && !oid.is_empty()).then_some(oid)
-}
-
-/// Run `git <args>` in `repo`, returning trimmed stdout or an error message.
-fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(args)
- .stdin(Stdio::null())
- .output()
- .map_err(|e| format!("could not run git: {e}"))?;
- if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
- } else {
- Err(String::from_utf8_lossy(&output.stderr).trim().to_owned())
- }
-}
-
-/// The session token in a `Cookie` header value, if present.
-fn token(cookie: &str) -> Option<String> {
- cookie.split(';').find_map(|pair| {
- let (name, value) = pair.trim().split_once('=')?;
- (name == COOKIE).then(|| value.to_owned())
- })
-}
-
-/// A fresh, unguessable token: 32 random bytes from the OS, hex-encoded.
-fn random_token() -> Result<String, String> {
- let mut bytes = [0u8; 32];
- getrandom::fill(&mut bytes).map_err(|e| format!("could not read randomness: {e}"))?;
- Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
-}
-
-/// One field's decoded value from an `application/x-www-form-urlencoded` body.
-pub(super) fn field(body: &[u8], name: &str) -> Option<String> {
- form(body).remove(name)
-}
-
-/// A form field's trimmed value, or the empty string.
-fn trimmed(fields: &HashMap<String, String>, name: &str) -> String {
- fields
- .get(name)
- .map(|v| v.trim())
- .unwrap_or_default()
- .to_owned()
-}
-
-/// Parse an `application/x-www-form-urlencoded` body into its fields.
-fn form(body: &[u8]) -> HashMap<String, String> {
- form_urlencoded::parse(body).into_owned().collect()
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::unwrap_used, reason = "unit test")]
- use super::*;
-
- #[test]
- fn normalizes_keys_by_dropping_the_comment() {
- assert_eq!(
- normalize_key("ssh-ed25519 AAAABODY laptop@host"),
- normalize_key("ssh-ed25519 AAAABODY web"),
- );
- }
-
- #[test]
- fn reads_a_keys_comment_as_its_label() {
- assert_eq!(
- key_comment("ssh-ed25519 AAAA laptop").as_deref(),
- Some("laptop")
- );
- assert_eq!(key_comment("ssh-ed25519 AAAA"), None);
- }
-
- #[test]
- fn decodes_form_fields() {
- assert_eq!(
- field(b"public_key=ssh-ed25519+AAAA&signature=a%0Ab", "public_key").as_deref(),
- Some("ssh-ed25519 AAAA"),
- );
- assert_eq!(
- field(b"public_key=ssh-ed25519+AAAA&signature=a%0Ab", "signature").as_deref(),
- Some("a\nb"),
- );
- }
-
- #[test]
- fn reads_the_session_cookie() {
- assert_eq!(
- token("other=1; ents_session=abc123; x=2").as_deref(),
- Some("abc123"),
- );
- assert_eq!(token("other=1").as_deref(), None);
- }
-
- #[test]
- fn a_consumed_challenge_does_not_verify_twice() {
- let challenges = new_challenges();
- let nonce = issue_challenge(&challenges).unwrap();
- assert!(take_challenge(&challenges, &nonce), "first use should pass");
- assert!(
- !take_challenge(&challenges, &nonce),
- "a challenge is one-time"
- );
- }
-}
crates/git-ents-server/templates/issues.html
@@ -1,30 +1,0 @@
-<div class="issues-head">
- <h1 class="page-title">Issues</h1>
- <button class="btn-primary" type="button" disabled title="Not available yet">{{ icons.icon_plus()|safe }}New issue</button>
-</div>
-{% if let Some(err) = error %}
-<div class="card"><div class="card-row muted">Could not read issues: {{ err }}</div></div>
-{% else %}
-<div class="filter-row">
- <div class="filter-search">
- {{ icons.icon_search()|safe }}
- <input type="search" placeholder="Filter issues" aria-label="Filter" disabled>
- </div>
- <span class="chip active">All</span>
- {% for label in labels %}<span class="chip">{{ label }}</span>{% endfor %}
-</div>
-<div class="card">
- <div class="card-header subtabs">
- <span class="subtab active">{{ icons.icon_issue()|safe }}Open<span class="tab-count">{{ open_count }}</span></span>
- <span class="subtab">{{ icons.icon_check()|safe }}Closed<span class="tab-count">{{ closed_count }}</span></span>
- </div>
- {% if open.is_empty() %}
- <div class="blankslate">
- <h2>No open issues</h2>
- <p>Open one to start tracking an issue.</p>
- </div>
- {% else %}
- {% for card in open %}{{ card|safe }}{% endfor %}
- {% endif %}
-</div>
-{% endif %}
crates/git-ents-server/tests/hydrate.rs
@@ -1,355 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
- reason = "integration test binary"
-)]
-
-//! End-to-end coverage for the WS0 hydration backend (`docs/scale-out.adoc`'s
-//! "WS0 Interim hydration backend" section).
-//!
-//! Two real `git push` invocations run over HTTP against a server
-//! configured with `GIT_ENTS_HYDRATE_POSTGRES_URL` and
-//! `GIT_ENTS_HYDRATE_BLOB_ROOT`, so every request goes through
-//! `git_hydrate`'s read/write paths rather than direct disk. The test then
-//! replays the corpus the write path logged, using
-//! `backend_conformance::replay_corpus`, against fresh
-//! `refstore-files`/`odb-files` backends, and asserts the replayed backend
-//! ends up with identical content refs and an identical reachable object
-//! set to the original Postgres/Tigris-backed repository: the conformance
-//! seed corpus `docs/scale-out.adoc` asks WS0 to produce.
-//!
-//! Gated on a reachable Postgres (`GIT_ENTS_TEST_POSTGRES_URL`, or a
-//! throwaway docker container), matching `refstore-postgres`'s own tests.
-//! See that crate's `tests/conformance.rs` module doc for the priority
-//! order and the visible-skip rationale.
-
-use std::collections::BTreeSet;
-use std::net::{TcpListener, TcpStream};
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-use std::time::Duration;
-
-use git_backend::{RefName, RefStore as _};
-use gix_hash::ObjectId;
-use odb_tigris::OdbTigris;
-use odb_tigris::transport::fs::FsTransport;
-use refstore_postgres::PostgresRefStore;
-
-const BIN: &str = env!("CARGO_BIN_EXE_git-ents-server");
-
-enum TestPostgres {
- External(String),
- Docker { container_id: String, url: String },
-}
-
-impl TestPostgres {
- fn url(&self) -> &str {
- match self {
- Self::External(url) | Self::Docker { url, .. } => url,
- }
- }
-}
-
-impl Drop for TestPostgres {
- fn drop(&mut self) {
- if let Self::Docker { container_id, .. } = self {
- let _ignored = Command::new("docker")
- .args(["rm", "-f", container_id])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- }
- }
-}
-
-fn test_postgres() -> Option<TestPostgres> {
- if let Ok(url) = std::env::var("GIT_ENTS_TEST_POSTGRES_URL") {
- return Some(TestPostgres::External(url));
- }
- if !docker_available() {
- return None;
- }
- start_docker_postgres()
-}
-
-fn docker_available() -> bool {
- Command::new("docker")
- .arg("version")
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map(|status| status.success())
- .unwrap_or(false)
-}
-
-fn start_docker_postgres() -> Option<TestPostgres> {
- let output = Command::new("docker")
- .args([
- "run",
- "-d",
- "--rm",
- "-e",
- "POSTGRES_PASSWORD=postgres",
- "-p",
- "127.0.0.1::5432",
- "postgres:16-alpine",
- ])
- .output()
- .ok()?;
- if !output.status.success() {
- eprintln!(
- "git-ents-server hydrate test: docker run failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- return None;
- }
- let container_id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
-
- for _ in 0..120 {
- let ready = Command::new("docker")
- .args(["exec", &container_id, "pg_isready", "-U", "postgres"])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .map(|status| status.success())
- .unwrap_or(false);
- if ready {
- break;
- }
- std::thread::sleep(Duration::from_millis(250));
- }
-
- let port_output = Command::new("docker")
- .args(["port", &container_id, "5432"])
- .output()
- .ok()?;
- let mapping = String::from_utf8_lossy(&port_output.stdout);
- let port = mapping
- .lines()
- .next()?
- .rsplit(':')
- .next()?
- .trim()
- .to_owned();
-
- let url = format!("host=127.0.0.1 port={port} user=postgres password=postgres dbname=postgres");
-
- // `pg_isready` above checks the container's internal socket, which can
- // report ready before the postgres entrypoint's post-initdb restart
- // finishes — a raw TCP connect can succeed against that transient
- // listener too. Only a real protocol-level connection confirms the
- // final server is actually up.
- if !wait_for_postgres_ready(&url) {
- eprintln!("git-ents-server hydrate test: postgres never became reachable");
- return None;
- }
-
- Some(TestPostgres::Docker { url, container_id })
-}
-
-fn wait_for_postgres_ready(url: &str) -> bool {
- for _ in 0..40 {
- if PostgresRefStore::connect(url, "readiness-probe").is_ok() {
- return true;
- }
- std::thread::sleep(Duration::from_millis(250));
- }
- false
-}
-
-macro_rules! require_postgres {
- ($name:literal) => {
- match test_postgres() {
- Some(pg) => pg,
- None => {
- eprintln!(concat!(
- "skipping ",
- $name,
- ": set GIT_ENTS_TEST_POSTGRES_URL, or make docker available"
- ));
- return;
- }
- }
- };
-}
-
-// @relation(protocol.git, storage.bare, role=Verifies)
-#[test]
-fn pushes_through_hydration_replay_identically_against_the_files_backends() {
- let pg =
- require_postgres!("pushes_through_hydration_replay_identically_against_the_files_backends");
- let repo_id = format!("hydrate-{}.git", uuid::Uuid::new_v4());
-
- let scratch = tempfile::tempdir().unwrap();
- let data = tempfile::tempdir().unwrap();
- let blob_root = tempfile::tempdir().unwrap();
- let hooks = tempfile::tempdir().unwrap();
-
- let hook = hooks.path().join("pre-receive");
- std::fs::write(&hook, format!("#!/bin/sh\nexec \"{BIN}\" pre-receive\n")).unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
- }
-
- let signing_key = keygen(scratch.path(), "op-signer");
-
- let port = free_port();
- let mut child = Command::new(BIN)
- .args(["--port", &port.to_string()])
- .arg("--data-dir")
- .arg(data.path())
- .arg("--hooks-dir")
- .arg(hooks.path())
- .arg("--web-signing-key")
- .arg(&signing_key)
- .arg("--checks-queue")
- .arg(scratch.path().join("checks-queue"))
- .env("GIT_ENTS_HYDRATE_POSTGRES_URL", pg.url())
- .env("GIT_ENTS_HYDRATE_BLOB_ROOT", blob_root.path())
- .spawn()
- .unwrap();
- wait_for_port(port);
-
- let url = format!("http://127.0.0.1:{port}/{repo_id}");
- let work = scratch.path().join("work");
- std::fs::create_dir_all(&work).unwrap();
- run(&work, "git", &["init", "-q", "-b", "main"]);
- std::fs::write(work.join("file.txt"), "one\n").unwrap();
- run(&work, "git", &["add", "."]);
- run(
- &work,
- "git",
- &["-c", "commit.gpgsign=false", "commit", "-q", "-m", "first"],
- );
- run(&work, "git", &["push", "-q", &url, "main"]);
-
- // A second push, so the corpus carries more than one entry and the
- // second's pack excludes the first's already-known objects.
- std::fs::write(work.join("file.txt"), "two\n").unwrap();
- run(&work, "git", &["add", "."]);
- run(
- &work,
- "git",
- &["-c", "commit.gpgsign=false", "commit", "-q", "-m", "second"],
- );
- run(&work, "git", &["push", "-q", &url, "main"]);
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- // The source of truth: Postgres refs, Tigris (here, `FsTransport`)
- // objects, and the corpus this repository's pushes logged.
- let source_refs = PostgresRefStore::connect(pg.url(), repo_id.clone()).unwrap();
- let source_registry = PostgresRefStore::connect(pg.url(), repo_id.clone()).unwrap();
- let source_transport = FsTransport::open(blob_root.path()).unwrap();
- let source_objects = OdbTigris::new(source_transport, source_registry, repo_id.clone());
-
- let entries = source_refs.corpus_log().unwrap();
- assert_eq!(
- entries.len(),
- 2,
- "both pushes should have logged a corpus entry"
- );
-
- let main = RefName::new("refs/heads/main");
- let source_main = source_refs.get(&main).unwrap();
- assert!(
- source_main.is_some(),
- "the pushed branch must exist in Postgres"
- );
-
- // Replay the corpus against fresh `refstore-files`/`odb-files` — the
- // conformance seed corpus (`docs/scale-out.adoc`, WS2) this backend
- // feeds.
- let target_dir = tempfile::tempdir().unwrap();
- run(target_dir.path(), "git", &["init", "-q", "--bare"]);
- let target_refs = refstore_files::FilesRefStore::open(target_dir.path()).unwrap();
- let target_objects = odb_files::OdbFiles::open(target_dir.path()).unwrap();
- backend_conformance::replay_corpus(&entries, &target_refs, &target_objects).unwrap();
-
- let target_main = target_refs.get(&main).unwrap();
- assert_eq!(
- target_main, source_main,
- "replaying the corpus must reproduce the same final ref"
- );
-
- let source_reachable = reachable_from_heads(&source_refs, &source_objects);
- let target_reachable = reachable_from_heads(&target_refs, &target_objects);
- assert_eq!(
- source_reachable, target_reachable,
- "replaying the corpus must reproduce the same reachable object set"
- );
-}
-
-/// The set of objects reachable from every `refs/heads/*` tip — deliberately
-/// narrower than `backend_conformance::reachable_object_set` (which walks
-/// every ref, including `refs/meta/ops/log`): the corpus intentionally does
-/// not carry the op-record chain (it re-signs on every replay, so it never
-/// hash-matches — see `git_protocol::corpus`'s module doc), so comparing the
-/// content refs' reachable closure is the correct scope for this assertion.
-fn reachable_from_heads(
- refs: &dyn git_backend::RefStore,
- objects: &dyn git_backend::ObjectStore,
-) -> BTreeSet<ObjectId> {
- let roots: Vec<ObjectId> = refs
- .iter_prefix(&RefName::new("refs/heads/"))
- .unwrap()
- .map(|entry| entry.unwrap().1)
- .collect();
- let source = gix_reachability::walk::StoreSource::new(objects);
- gix_reachability::walk::reachable(roots, &source, |_id| false, false).unwrap()
-}
-
-fn keygen(base: &Path, name: &str) -> PathBuf {
- let key = base.join(name);
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"])
- .arg(&key)
- .status()
- .unwrap();
- assert!(status.success(), "ssh-keygen failed");
- key
-}
-
-fn free_port() -> u16 {
- let probe = TcpListener::bind("127.0.0.1:0").unwrap();
- let port = probe.local_addr().unwrap().port();
- drop(probe);
- port
-}
-
-fn wait_for_port(port: u16) {
- let deadline = std::time::Instant::now() + Duration::from_secs(5);
- loop {
- match TcpStream::connect(format!("127.0.0.1:{port}")) {
- Ok(_) => return,
- Err(_) if std::time::Instant::now() < deadline => {
- std::thread::sleep(Duration::from_millis(10));
- }
- Err(error) => panic!("server never accepted connections: {error}"),
- }
- }
-}
-
-fn run(dir: &Path, program: &str, args: &[&str]) {
- let output = Command::new(program)
- .current_dir(dir)
- .args(args)
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_SYSTEM", "/dev/null")
- .env("GIT_AUTHOR_NAME", "T")
- .env("GIT_AUTHOR_EMAIL", "t@e")
- .env("GIT_COMMITTER_NAME", "T")
- .env("GIT_COMMITTER_EMAIL", "t@e")
- .output()
- .unwrap();
- assert!(
- output.status.success(),
- "{program} {args:?} failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
crates/git-ents-server/tests/native_protocol.rs
@@ -1,341 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
- reason = "integration test binary"
-)]
-
-//! End-to-end coverage for the native `git-protocol` smart-HTTP path
-//! (`crate::native_git`), mounted under `/_native/`, all against a stock
-//! `git` binary. `clone`/`fetch` is proven with zero client configuration,
-//! per WS3's read-path interop requirement. The write path is proven at
-//! every attestation stage: a bootstrap-window push (no members enrolled),
-//! an unsigned push rejected once a member is enrolled, and a real
-//! `git push --signed` (SSH key, the `push.gpgSign` mechanics) accepted
-//! with its op record — client certificate embedded by OID — chained under
-//! `refs/meta/ops/log`.
-
-use std::net::{TcpListener, TcpStream};
-use std::path::Path;
-use std::process::Command;
-
-// @relation(protocol.git, compat.git, role=Verifies)
-#[test]
-fn clones_over_the_native_endpoint_after_a_push_over_the_cgi_endpoint() {
- let data = tempfile::tempdir().unwrap();
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .spawn()
- .unwrap();
- wait_for_port(port);
-
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
- let pushed = rev_parse(src.path());
- run_git(
- Some(src.path()),
- &[
- "push",
- "-q",
- &format!("http://127.0.0.1:{port}/test.git"),
- "main",
- ],
- );
-
- let dst = tempfile::tempdir().unwrap();
- let clone_path = dst.path().join("clone");
- run_git(
- None,
- &[
- "clone",
- "-q",
- &format!("http://127.0.0.1:{port}/_native/test.git"),
- clone_path.to_str().unwrap(),
- ],
- );
- let cloned = rev_parse(&clone_path);
- let content = std::fs::read_to_string(clone_path.join("README.md")).unwrap();
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert_eq!(pushed, cloned, "cloned HEAD must match the pushed HEAD");
- assert_eq!(content, "hello ents\n");
-}
-
-// @relation(protocol.git, auth.signed-push, role=Verifies)
-#[test]
-fn native_push_during_bootstrap_lands_and_emits_an_op_record() {
- let data = tempfile::tempdir().unwrap();
- let scratch = tempfile::tempdir().unwrap();
- let server_key = scratch.path().join("op-signing-key");
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-f"])
- .arg(&server_key)
- .status()
- .unwrap();
- assert!(status.success(), "ssh-keygen failed");
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .arg("--web-signing-key")
- .arg(&server_key)
- .spawn()
- .unwrap();
- wait_for_port(port);
-
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
- let pushed = rev_parse(src.path());
-
- run_git(
- Some(src.path()),
- &[
- "push",
- "-q",
- &format!("http://127.0.0.1:{port}/_native/pushed.git"),
- "main",
- ],
- );
-
- let repo_on_disk = data.path().join("pushed.git");
- let op_log = rev_parse_ref(&repo_on_disk, "refs/meta/ops/log");
- let landed = rev_parse_ref(&repo_on_disk, "refs/heads/main");
- let op_record = git_command(
- Some(&repo_on_disk),
- &["cat-file", "-p", "refs/meta/ops/log"],
- )
- .output()
- .unwrap();
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert_eq!(landed, pushed);
- assert!(
- !op_log.is_empty(),
- "op record ref must exist after an accepted push"
- );
- let op_record_text = String::from_utf8_lossy(&op_record.stdout);
- assert!(
- op_record_text.contains("push-cert"),
- "op record must embed the push certificate by OID: {op_record_text}"
- );
- assert!(
- op_record_text.contains(&format!("refs/heads/main {} {pushed}", "0".repeat(40))),
- "op record must record the applied ref edit: {op_record_text}"
- );
-}
-
-// @relation(protocol.git, auth.signed-push, role=Verifies)
-#[test]
-fn signed_push_is_accepted_and_unsigned_rejected_once_a_member_is_enrolled() {
- let data = tempfile::tempdir().unwrap();
- let scratch = tempfile::tempdir().unwrap();
- let server_key = keygen(scratch.path(), "op-signing-key");
- let member_key = keygen(scratch.path(), "member-key");
- let member_pub = std::fs::read_to_string(scratch.path().join("member-key.pub")).unwrap();
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .arg("--web-signing-key")
- .arg(&server_key)
- .spawn()
- .unwrap();
- wait_for_port(port);
- let url = format!("http://127.0.0.1:{port}/_native/attested.git");
-
- // Bootstrap: first push with no members enrolled creates the repo.
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
- run_git(Some(src.path()), &["push", "-q", &url, "main"]);
-
- // Enroll the member key directly on the served bare repo — the same
- // refs/meta/member layout pre-receive trusts.
- let repo_on_disk = data.path().join("attested.git");
- git_member::members::store(
- &repo_on_disk,
- &git_member::members::Member {
- principal: "alice".to_owned(),
- valid_after: None,
- valid_before: None,
- trust: git_member::members::Trust::Keys(
- std::iter::once(("fp1".to_owned(), member_pub)).collect(),
- ),
- provenance: git_member::members::Provenance::AdminRegistered,
- account: None,
- role: None,
- },
- )
- .unwrap();
- let op_log_before = rev_parse_ref(&repo_on_disk, "refs/meta/ops/log");
- assert!(
- !op_log_before.is_empty(),
- "bootstrap push must have logged an op record"
- );
-
- // A second commit: unsigned push must now be rejected...
- std::fs::write(src.path().join("README.md"), "hello again\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "second"]);
- let second = rev_parse(src.path());
- let unsigned = git_command(Some(src.path()), &["push", "-q", &url, "main"])
- .output()
- .unwrap();
- assert!(
- !unsigned.status.success(),
- "unsigned push must be rejected once a member is enrolled: {}",
- String::from_utf8_lossy(&unsigned.stderr)
- );
-
- // ...and the same push signed with the enrolled key must land.
- run_git(
- Some(src.path()),
- &[
- "-c",
- "gpg.format=ssh",
- "-c",
- &format!("user.signingKey={}", member_key.display()),
- "push",
- "-q",
- "--signed",
- &url,
- "main",
- ],
- );
-
- let landed = rev_parse_ref(&repo_on_disk, "refs/heads/main");
- let op_log_after = rev_parse_ref(&repo_on_disk, "refs/meta/ops/log");
- let op_record = git_command(
- Some(&repo_on_disk),
- &["cat-file", "-p", "refs/meta/ops/log"],
- )
- .output()
- .unwrap();
- let op_record_text = String::from_utf8_lossy(&op_record.stdout).into_owned();
- let cert_oid = op_record_text
- .lines()
- .find_map(|line| line.strip_prefix("push-cert "))
- .unwrap_or_default()
- .to_owned();
- let cert = git_command(Some(&repo_on_disk), &["cat-file", "blob", &cert_oid])
- .output()
- .unwrap();
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert_eq!(landed, second, "the signed push must have landed");
- assert_ne!(
- op_log_after, op_log_before,
- "the signed push must have chained a new op record"
- );
- let cert_text = String::from_utf8_lossy(&cert.stdout);
- assert!(
- cert_text.contains("BEGIN SSH SIGNATURE"),
- "the embedded push certificate must carry the client's signature: {cert_text}"
- );
- assert!(
- cert_text.contains(&second),
- "the embedded push certificate must name the pushed commit: {cert_text}"
- );
-}
-
-/// Generate an ed25519 keypair at `base/<name>`, returning the private key
-/// path.
-fn keygen(base: &Path, name: &str) -> std::path::PathBuf {
- let key = base.join(name);
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-f"])
- .arg(&key)
- .status()
- .unwrap();
- assert!(status.success(), "ssh-keygen failed");
- key
-}
-
-fn free_port() -> u16 {
- let probe = TcpListener::bind("127.0.0.1:0").unwrap();
- let port = probe.local_addr().unwrap().port();
- drop(probe);
- port
-}
-
-fn wait_for_port(port: u16) {
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
- loop {
- match TcpStream::connect(format!("127.0.0.1:{port}")) {
- Ok(_) => return,
- Err(_) if std::time::Instant::now() < deadline => {
- std::thread::sleep(std::time::Duration::from_millis(10));
- }
- Err(e) => panic!("server never accepted connections: {e}"),
- }
- }
-}
-
-fn run_git(dir: Option<&Path>, args: &[&str]) {
- let output = git_command(dir, args).output().unwrap();
- assert!(
- output.status.success(),
- "git {args:?} failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
-
-fn rev_parse(dir: &Path) -> String {
- rev_parse_ref(dir, "HEAD")
-}
-
-fn rev_parse_ref(dir: &Path, refname: &str) -> String {
- let output = git_command(Some(dir), &["rev-parse", "--verify", "--quiet", refname])
- .output()
- .unwrap();
- if !output.status.success() {
- return String::new();
- }
- String::from_utf8(output.stdout).unwrap().trim().to_owned()
-}
-
-fn git_command(dir: Option<&Path>, args: &[&str]) -> Command {
- let mut cmd = Command::new("git");
- cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_SYSTEM", "/dev/null")
- .env("GIT_TERMINAL_PROMPT", "0");
- if let Some(dir) = dir {
- cmd.arg("-C").arg(dir);
- }
- cmd.args([
- "-c",
- "user.name=Ent Test",
- "-c",
- "user.email=ent@example.com",
- "-c",
- "commit.gpgsign=false",
- ]);
- cmd.args(args);
- cmd
-}
crates/git-ents-server/tests/pre_receive.rs
@@ -1,410 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
- clippy::unused_result_ok,
- reason = "integration test binary"
-)]
-
-//! End-to-end coverage for the `pre-receive` signed-push verifier: a real
-//! `git push --signed` over the `file://` transport against a bare repo whose
-//! hook is the compiled `git-ents-server pre-receive` subcommand.
-
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-use std::sync::atomic::{AtomicUsize, Ordering};
-
-const BIN: &str = env!("CARGO_BIN_EXE_git-ents-server");
-
-/// Run a command and assert it succeeds, returning trimmed stdout.
-fn ok(dir: &Path, program: &str, args: &[&str]) -> String {
- let output = git_env(dir, program, args)
- .stdin(Stdio::null())
- .output()
- .unwrap();
- assert!(
- output.status.success(),
- "{program} {args:?} failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- String::from_utf8_lossy(&output.stdout).trim().to_owned()
-}
-
-/// Build a command in `dir` with a fixed committer/pusher identity.
-fn git_env(dir: &Path, program: &str, args: &[&str]) -> Command {
- let mut command = Command::new(program);
- command
- .current_dir(dir)
- .args(args)
- .env("GIT_AUTHOR_NAME", "Tester")
- .env("GIT_AUTHOR_EMAIL", "tester@example.com")
- .env("GIT_COMMITTER_NAME", "Tester")
- .env("GIT_COMMITTER_EMAIL", "tester@example.com");
- command
-}
-
-fn unique_dir(tag: &str) -> PathBuf {
- static COUNTER: AtomicUsize = AtomicUsize::new(0);
- let n = COUNTER.fetch_add(1, Ordering::SeqCst);
- let dir =
- std::env::temp_dir().join(format!("git-ents-prerecv-{tag}-{}-{n}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
- dir
-}
-
-/// Generate an ed25519 keypair at `base/<name>`, returning the public key path.
-fn keygen(base: &Path, name: &str) -> PathBuf {
- let key = base.join(name);
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"])
- .arg(&key)
- .status()
- .unwrap();
- assert!(status.success(), "ssh-keygen failed");
- base.join(format!("{name}.pub"))
-}
-
-/// Create a bare server repo wired to the `pre-receive` verifier, listing the
-/// public keys at `authorized` as signers with no validity window.
-fn server_repo(base: &Path, authorized: &[&Path]) -> PathBuf {
- let members: Vec<Member> = authorized
- .iter()
- .map(|pubkey| Member {
- pubkey,
- valid_after: None,
- valid_before: None,
- })
- .collect();
- server_repo_with(base, &members)
-}
-
-/// One authorized member written into the test `refs/meta/members` doc: a public
-/// key and the validity window it is trusted within.
-struct Member<'a> {
- pubkey: &'a Path,
- valid_after: Option<&'a str>,
- valid_before: Option<&'a str>,
-}
-
-/// Create a bare server repo wired to the `pre-receive` verifier with one
-/// `refs/meta/member/member-<n>` ref per member in the real on-disk layout — a
-/// `principal` blob, `valid_after`/`valid_before` `Option` subtrees, and a
-/// `trust/Keys/key` blob.
-fn server_repo_with(base: &Path, members: &[Member]) -> PathBuf {
- let repo = base.join("srv.git");
- ok(
- base,
- "git",
- &["init", "--bare", "-q", repo.to_str().unwrap()],
- );
- ok(
- &repo,
- "git",
- &["config", "receive.certNonceSeed", "test-seed"],
- );
-
- let hook = repo.join("hooks").join("pre-receive");
- std::fs::write(&hook, format!("#!/bin/sh\nexec \"{BIN}\" pre-receive\n")).unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
- }
-
- let option_tree = |bound: Option<&str>| match bound {
- None => mktree(&repo, ""),
- Some(value) => {
- let blob = hash_object(&repo, value.as_bytes());
- mktree(&repo, &format!("100644 blob {blob}\tsome\n"))
- }
- };
- for (index, member) in members.iter().enumerate() {
- let username = format!("member-{index}");
- let principal_blob = hash_object(&repo, username.as_bytes());
- let key = std::fs::read_to_string(member.pubkey).unwrap();
- let key_blob = hash_object(&repo, key.as_bytes());
- let keys_tree = mktree(&repo, &format!("100644 blob {key_blob}\tkey\n"));
- let trust_tree = mktree(&repo, &format!("040000 tree {keys_tree}\tKeys\n"));
- let after_tree = option_tree(member.valid_after);
- let before_tree = option_tree(member.valid_before);
- let root_tree = mktree(
- &repo,
- &format!(
- "100644 blob {principal_blob}\tprincipal\n\
- 040000 tree {after_tree}\tvalid_after\n\
- 040000 tree {before_tree}\tvalid_before\n\
- 040000 tree {trust_tree}\ttrust\n"
- ),
- );
- let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "member"]);
- ok(
- &repo,
- "git",
- &[
- "update-ref",
- &format!("refs/meta/member/{username}"),
- &commit,
- ],
- );
- }
- repo
-}
-
-/// Deny `fingerprint` on the server's `refs/meta/revoked` ref, in the real
-/// on-disk `revoked/<fingerprint>` blob layout. The members helper records each
-/// member's key under the fingerprint `key`.
-fn revoke(repo: &Path, fingerprint: &str) {
- let reason_blob = hash_object(repo, b"compromised");
- let revoked_tree = mktree(repo, &format!("100644 blob {reason_blob}\t{fingerprint}\n"));
- let root_tree = mktree(repo, &format!("040000 tree {revoked_tree}\trevoked\n"));
- let commit = ok(repo, "git", &["commit-tree", &root_tree, "-m", "revoke"]);
- ok(repo, "git", &["update-ref", "refs/meta/revoked", &commit]);
-}
-
-fn hash_object(repo: &Path, bytes: &[u8]) -> String {
- pipe(repo, &["hash-object", "-w", "--stdin"], bytes)
-}
-
-fn mktree(repo: &Path, spec: &str) -> String {
- pipe(repo, &["mktree"], spec.as_bytes())
-}
-
-/// Run `git <args>` in `repo`, feeding `input` on stdin, returning trimmed stdout.
-fn pipe(repo: &Path, args: &[&str], input: &[u8]) -> String {
- use std::io::Write;
- let mut child = git_env(repo, "git", args)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .spawn()
- .unwrap();
- child.stdin.take().unwrap().write_all(input).unwrap();
- let output = child.wait_with_output().unwrap();
- assert!(output.status.success(), "git {args:?} failed");
- String::from_utf8_lossy(&output.stdout).trim().to_owned()
-}
-
-/// Create a work repo with one commit, signing with `signing_key` if given.
-fn work_repo(base: &Path, signing_key: Option<&Path>) -> PathBuf {
- let repo = base.join("work");
- std::fs::create_dir_all(&repo).unwrap();
- ok(&repo, "git", &["init", "-q", "-b", "main"]);
- if let Some(key) = signing_key {
- ok(&repo, "git", &["config", "gpg.format", "ssh"]);
- ok(
- &repo,
- "git",
- &["config", "user.signingkey", key.to_str().unwrap()],
- );
- }
- std::fs::write(repo.join("file.txt"), "hello\n").unwrap();
- ok(&repo, "git", &["add", "file.txt"]);
- ok(&repo, "git", &["commit", "-q", "-m", "initial"]);
- repo
-}
-
-/// Attempt a push, returning whether it succeeded.
-fn push(work: &Path, server: &Path, signed: bool) -> bool {
- push_ref(work, server, signed, "main:refs/heads/main")
-}
-
-/// Attempt a push of `refspec`, returning whether it succeeded.
-fn push_ref(work: &Path, server: &Path, signed: bool, refspec: &str) -> bool {
- let url = format!("file://{}", server.display());
- let mut args = vec!["push"];
- if signed {
- args.push("--signed");
- }
- args.extend_from_slice(&[url.as_str(), refspec]);
- git_env(work, "git", &args)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .unwrap()
- .success()
-}
-
-// @relation(auth.signed-push, compat.ssh-keygen, compat.openssh-signed-push, role=Verifies)
-#[test]
-fn accepts_a_push_signed_by_an_authorized_key() {
- let base = unique_dir("accept");
- let pubkey = keygen(&base, "id");
- let server = server_repo(&base, &[&pubkey]);
- let work = work_repo(&base, Some(&pubkey));
-
- assert!(
- push(&work, &server, true),
- "authorized signed push was rejected"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-// @relation(auth.signed-push, compat.ssh-keygen, role=Verifies)
-#[test]
-fn rejects_a_push_signed_by_an_unknown_key() {
- let base = unique_dir("unknown");
- let authorized = keygen(&base, "authorized");
- let intruder = keygen(&base, "intruder");
- let server = server_repo(&base, &[&authorized]);
- let work = work_repo(&base, Some(&intruder));
-
- assert!(
- !push(&work, &server, true),
- "push by an unknown key was accepted"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-// @relation(auth.signed-push, compat.openssh-signed-push, role=Verifies)
-#[test]
-fn rejects_an_unsigned_push_when_signers_exist() {
- let base = unique_dir("unsigned");
- let pubkey = keygen(&base, "id");
- let server = server_repo(&base, &[&pubkey]);
- let work = work_repo(&base, None);
-
- assert!(!push(&work, &server, false), "unsigned push was accepted");
- std::fs::remove_dir_all(&base).ok();
-}
-
-#[test]
-fn accepts_a_push_signed_by_an_in_window_key() {
- let base = unique_dir("inwindow");
- let pubkey = keygen(&base, "id");
- let server = server_repo_with(
- &base,
- &[Member {
- pubkey: &pubkey,
- valid_after: Some("20200101"),
- valid_before: Some("20990101"),
- }],
- );
- let work = work_repo(&base, Some(&pubkey));
-
- assert!(
- push(&work, &server, true),
- "in-window signed push was rejected"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-#[test]
-fn rejects_a_push_signed_by_an_expired_key() {
- // The window lapsed before today, so the key no longer authorizes a new
- // push — staleness fails closed. This is the Phase 1 security gate: if the
- // verifier ignored `valid-before`, this push would be accepted.
- let base = unique_dir("expired");
- let pubkey = keygen(&base, "id");
- let server = server_repo_with(
- &base,
- &[Member {
- pubkey: &pubkey,
- valid_after: None,
- valid_before: Some("20200101"),
- }],
- );
- let work = work_repo(&base, Some(&pubkey));
-
- assert!(
- !push(&work, &server, true),
- "push signed by an expired-window key was accepted"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-#[test]
-fn rejects_a_push_signed_before_a_keys_window_opens() {
- let base = unique_dir("future");
- let pubkey = keygen(&base, "id");
- let server = server_repo_with(
- &base,
- &[Member {
- pubkey: &pubkey,
- valid_after: Some("20990101"),
- valid_before: None,
- }],
- );
- let work = work_repo(&base, Some(&pubkey));
-
- assert!(
- !push(&work, &server, true),
- "push signed before the key's window opened was accepted"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-// @relation(auth.signed-push, role=Verifies)
-#[test]
-fn rejects_a_push_signed_by_a_revoked_key() {
- // The key is a valid, in-window member, but its fingerprint is on the
- // `refs/meta/revoked` deny list, so the verifier subtracts it and the push
- // is refused — revocation faster than expiry.
- let base = unique_dir("revoked");
- let pubkey = keygen(&base, "id");
- let server = server_repo(&base, &[&pubkey]);
- revoke(&server, "key");
- let work = work_repo(&base, Some(&pubkey));
-
- assert!(
- !push(&work, &server, true),
- "push signed by a revoked key was accepted"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-// @relation(checks.admin-only, role=Verifies)
-#[test]
-fn rejects_a_push_to_effects_from_a_self_attested_member() {
- // A self-attested (non-admin-registered) member can still sign an
- // ordinary content push, but is refused for `refs/meta/effects/*`
- // regardless of any role rule: authoring an effect schedules code
- // execution, which the admin-only rule guards unconditionally.
- let base = unique_dir("effects-admin");
- let pubkey = keygen(&base, "id");
- let server = server_repo(&base, &[]);
-
- let mut keys = std::collections::BTreeMap::new();
- keys.insert("key".to_owned(), std::fs::read_to_string(&pubkey).unwrap());
- let member = git_member::members::Member {
- principal: "self-attested".to_owned(),
- valid_after: None,
- valid_before: None,
- trust: git_member::members::Trust::Keys(keys),
- provenance: git_member::members::Provenance::SelfAttestedWeb,
- account: None,
- role: None,
- };
- git_member::members::store(&server, &member).unwrap();
-
- let work = work_repo(&base, Some(&pubkey));
- let tree = ok(&work, "git", &["write-tree"]);
- let commit = ok(&work, "git", &["commit-tree", &tree, "-m", "effect"]);
- ok(
- &work,
- "git",
- &["update-ref", "refs/heads/effect-tmp", &commit],
- );
-
- assert!(
- !push_ref(
- &work,
- &server,
- true,
- "refs/heads/effect-tmp:refs/meta/effects/demo",
- ),
- "self-attested member was allowed to push to refs/meta/effects/*"
- );
- std::fs::remove_dir_all(&base).ok();
-}
-
-// @relation(auth.bootstrap, role=Verifies)
-#[test]
-fn accepts_any_push_before_signers_are_configured() {
- let base = unique_dir("bootstrap");
- let server = server_repo(&base, &[]);
- let work = work_repo(&base, None);
-
- assert!(push(&work, &server, false), "bootstrap push was rejected");
- std::fs::remove_dir_all(&base).ok();
-}
crates/git-ents-server/tests/server.rs
@@ -1,226 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
- reason = "integration test binary"
-)]
-
-use std::io::{Read, Write};
-use std::net::{TcpListener, TcpStream};
-use std::path::Path;
-use std::process::Command;
-
-use rstest::rstest;
-
-// @relation(web.server-rendered, web.index, server.embeddable, role=Verifies)
-#[test]
-fn responds_to_requests() {
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .spawn()
- .unwrap();
-
- wait_for_port(port);
-
- for i in 0..3 {
- let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
- .unwrap_or_else(|e| panic!("could not connect on request {i}: {e}"));
- stream.write_all(b"GET / HTTP/1.0\r\n\r\n").unwrap();
- let mut response = String::new();
- stream.read_to_string(&mut response).unwrap();
- assert!(
- response.contains("200 OK"),
- "unexpected response: {response}"
- );
- }
-
- child.kill().unwrap();
- let _wait = child.wait();
-}
-
-// @relation(storage.bare, namespace.auto-create, protocol.git, role=Verifies)
-#[test]
-fn push_then_clone_round_trip() {
- let data = tempfile::tempdir().unwrap();
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .spawn()
- .unwrap();
-
- wait_for_port(port);
-
- let url = format!("http://127.0.0.1:{port}/test.git");
-
- // Build a source repo with one commit on `main` and push it (auto-init).
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
- run_git(Some(src.path()), &["push", "-q", &url, "main"]);
- let pushed = rev_parse(src.path());
-
- // Clone it back and confirm the objects round-trip.
- let dst = tempfile::tempdir().unwrap();
- let clone_path = dst.path().join("clone");
- run_git(None, &["clone", "-q", &url, clone_path.to_str().unwrap()]);
- let cloned = rev_parse(&clone_path);
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert_eq!(pushed, cloned, "cloned HEAD must match pushed HEAD");
-}
-
-// @relation(namespace.path, namespace.auto-create, role=Verifies)
-#[rstest]
-#[case("org/repo")]
-#[case("org/team/repo")]
-fn nested_push_then_clone_round_trip(#[case] name: &str) {
- let data = tempfile::tempdir().unwrap();
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .spawn()
- .unwrap();
-
- wait_for_port(port);
-
- let url = format!("http://127.0.0.1:{port}/{name}.git");
-
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
- run_git(Some(src.path()), &["push", "-q", &url, "main"]);
- let pushed = rev_parse(src.path());
-
- let dst = tempfile::tempdir().unwrap();
- let clone_path = dst.path().join("clone");
- run_git(None, &["clone", "-q", &url, clone_path.to_str().unwrap()]);
- let cloned = rev_parse(&clone_path);
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert_eq!(
- pushed, cloned,
- "cloned HEAD must match pushed HEAD for {name}"
- );
-}
-
-// @relation(namespace.auto-create, role=Verifies)
-#[rstest]
-#[case("org/repo.git/deep.git")] // nested inside an existing repository
-#[case("org")] // already exists as a namespace
-fn rejects_colliding_pushes(#[case] collide: &str) {
- let data = tempfile::tempdir().unwrap();
- let port = free_port();
-
- let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server"))
- .arg("--port")
- .arg(port.to_string())
- .arg("--data-dir")
- .arg(data.path())
- .spawn()
- .unwrap();
-
- wait_for_port(port);
-
- let src = tempfile::tempdir().unwrap();
- run_git(Some(src.path()), &["init", "-q", "-b", "main"]);
- std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap();
- run_git(Some(src.path()), &["add", "."]);
- run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]);
-
- // Claim `org/repo.git`, which also makes `org` a namespace directory.
- let base = format!("http://127.0.0.1:{port}/org/repo.git");
- run_git(Some(src.path()), &["push", "-q", &base, "main"]);
-
- // A push that collides with that repository must be refused.
- let url = format!("http://127.0.0.1:{port}/{collide}");
- let rejected = !git_command(Some(src.path()), &["push", "-q", &url, "main"])
- .output()
- .unwrap()
- .status
- .success();
-
- child.kill().unwrap();
- let _wait = child.wait();
-
- assert!(
- rejected,
- "push colliding with an existing repo ({collide}) must fail"
- );
-}
-
-fn free_port() -> u16 {
- let probe = TcpListener::bind("127.0.0.1:0").unwrap();
- let port = probe.local_addr().unwrap().port();
- drop(probe);
- port
-}
-
-fn wait_for_port(port: u16) {
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
- loop {
- match TcpStream::connect(format!("127.0.0.1:{port}")) {
- Ok(_) => return,
- Err(_) if std::time::Instant::now() < deadline => {
- std::thread::sleep(std::time::Duration::from_millis(10));
- }
- Err(e) => panic!("server never accepted connections: {e}"),
- }
- }
-}
-
-fn run_git(dir: Option<&Path>, args: &[&str]) {
- let output = git_command(dir, args).output().unwrap();
- assert!(
- output.status.success(),
- "git {args:?} failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
-
-fn rev_parse(dir: &Path) -> String {
- let output = git_command(Some(dir), &["rev-parse", "HEAD"])
- .output()
- .unwrap();
- assert!(output.status.success());
- String::from_utf8(output.stdout).unwrap().trim().to_owned()
-}
-
-fn git_command(dir: Option<&Path>, args: &[&str]) -> Command {
- let mut cmd = Command::new("git");
- cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_SYSTEM", "/dev/null")
- .env("GIT_TERMINAL_PROMPT", "0");
- if let Some(dir) = dir {
- cmd.arg("-C").arg(dir);
- }
- cmd.args([
- "-c",
- "user.name=Ent Test",
- "-c",
- "user.email=ent@example.com",
- "-c",
- "commit.gpgsign=false",
- ]);
- cmd.args(args);
- cmd
-}
crates/git-ents-server/tests/web_edit.rs
@@ -1,596 +1,0 @@
-#![allow(
- missing_docs,
- clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
- reason = "integration test binary"
-)]
-
-//! End-to-end coverage for authenticated browser edits: proving control of a
-//! member key by signing a one-time challenge (the key never leaves the client),
-//! then saving a settings change that travels through the real `pre-receive`
-//! gate — signed with the server's own key, authored by the member — before it
-//! lands on `refs/meta/config`.
-
-use std::io::{Read, Write};
-use std::net::{TcpListener, TcpStream};
-use std::path::{Path, PathBuf};
-use std::process::{Child, Command, Stdio};
-
-const BIN: &str = env!("CARGO_BIN_EXE_git-ents-server");
-const LOGIN_NAMESPACE: &str = "git.ents.cloud";
-
-// @relation(web.tabs, web.auth.edit, web.auth.challenge, web.auth.session, role=Verifies)
-#[test]
-fn a_member_edits_settings_through_the_browser() {
- let env = Server::start();
- let bare = env.create_repo("repo.git");
- env.add_server_member(&bare);
- let alice = keygen(env.scratch(), "alice");
- env.add_member(&bare, "alice", &pubkey(&alice));
-
- let cookie = env.sign_in(&alice);
-
- // The settings page now offers an edit form; read its CSRF token.
- let page = env.get("/repo.git/settings", &cookie);
- assert!(
- page.body.contains("name=\"csrf\""),
- "edit form should render"
- );
- let csrf = page.field("csrf").unwrap();
-
- let edit = env.post(
- "/repo.git/settings",
- &cookie,
- &form(&[
- ("csrf", &csrf),
- ("description", "Edited from the browser"),
- ("homepage", "https://ents.example"),
- ("topics", "rust, git, forge"),
- ]),
- );
- assert_eq!(
- edit.status, 303,
- "a valid edit should redirect: {}",
- edit.body
- );
-
- // The change is reflected because it landed on `refs/meta/config`: the page
- // re-reads the ref, it is not echoing the submitted form.
- let after = env.get("/repo.git/settings", &cookie);
- assert!(
- after.body.contains("Edited from the browser"),
- "description did not land"
- );
- assert!(
- after.body.contains("https://ents.example"),
- "homepage did not land"
- );
- assert!(after.body.contains("forge"), "topics did not land");
-
- // "$USERNAME via Web": the commit is authored by the member and committed by
- // the server identity.
- assert_eq!(
- git(&bare, &["log", "-1", "--format=%an", "refs/meta/config"]).as_deref(),
- Some("alice"),
- "the edit should be authored by the member"
- );
- assert_eq!(
- git(&bare, &["log", "-1", "--format=%cn", "refs/meta/config"]).as_deref(),
- Some("git-ents"),
- "the committer should be the server identity"
- );
-}
-
-// @relation(web.auth.session, role=Verifies)
-#[test]
-fn an_edit_without_a_valid_csrf_token_is_refused() {
- let env = Server::start();
- let bare = env.create_repo("repo.git");
- env.add_server_member(&bare);
- let alice = keygen(env.scratch(), "alice");
- env.add_member(&bare, "alice", &pubkey(&alice));
-
- let cookie = env.sign_in(&alice);
- let edit = env.post(
- "/repo.git/settings",
- &cookie,
- &form(&[("csrf", "not-the-token"), ("description", "sneaky")]),
- );
- assert_eq!(edit.status, 200, "a bad-CSRF edit should not redirect");
- assert!(
- !env.get("/repo.git/settings", &cookie)
- .body
- .contains("sneaky"),
- "the description must be unchanged"
- );
-}
-
-// @relation(web.auth.edit, role=Verifies)
-#[test]
-fn a_self_attested_member_is_refused_a_settings_edit() {
- let env = Server::start();
- let bare = env.create_repo("repo.git");
- env.add_server_member(&bare);
- let alice = keygen(env.scratch(), "alice");
- env.add_self_attested_member(&bare, "alice", &pubkey(&alice));
-
- let cookie = env.sign_in(&alice);
- let page = env.get("/repo.git/settings", &cookie);
- assert!(
- page.body.contains("name=\"csrf\""),
- "the edit form should still render for a self-attested member"
- );
- let csrf = page.field("csrf").unwrap();
-
- let edit = env.post(
- "/repo.git/settings",
- &cookie,
- &form(&[
- ("csrf", &csrf),
- ("description", "should not land"),
- ("homepage", ""),
- ("topics", ""),
- ]),
- );
- assert_eq!(
- edit.status, 200,
- "a self-attested member's edit should not redirect: {}",
- edit.body
- );
- assert!(
- !env.get("/repo.git/settings", &cookie)
- .body
- .contains("should not land"),
- "the description must be unchanged"
- );
-}
-
-// @relation(web.auth.challenge, role=Verifies)
-#[test]
-fn a_non_member_is_not_offered_an_edit_form() {
- let env = Server::start();
- let bare = env.create_repo("repo.git");
- env.add_server_member(&bare);
- let member = keygen(env.scratch(), "member");
- env.add_member(&bare, "alice", &pubkey(&member));
-
- // A real key that is simply not a member of this repo signs in fine — a
- // session proves key control, not authority.
- let intruder = keygen(env.scratch(), "intruder");
- let cookie = env.sign_in(&intruder);
-
- let page = env.get("/repo.git/settings", &cookie);
- assert!(
- page.body.contains("not a member"),
- "a non-member should be told they cannot edit"
- );
- assert!(
- !page.body.contains("Save changes"),
- "a non-member should not see the edit form"
- );
-}
-
-// @relation(web.auth.challenge, role=Verifies)
-#[test]
-fn a_signature_that_does_not_match_the_public_key_is_refused() {
- let env = Server::start();
- let alice = keygen(env.scratch(), "alice");
- let mallory = keygen(env.scratch(), "mallory");
-
- // Sign the challenge with mallory's key but claim alice's public key.
- let nonce = env.get("/login", "").field("nonce").unwrap();
- let signature = sign_nonce(&mallory, &nonce);
- let attempt = env.post(
- "/login",
- "",
- &form(&[
- ("nonce", &nonce),
- ("public_key", &pubkey(&alice)),
- ("signature", &signature),
- ]),
- );
- assert_eq!(
- attempt.status, 200,
- "a mismatched signature must not open a session"
- );
- assert!(
- attempt.session_cookie().is_none(),
- "no session cookie should be set"
- );
-}
-
-/// A running server enforcing the signed-push gate and holding a web signing key.
-struct Server {
- child: Child,
- port: u16,
- data: tempfile::TempDir,
- scratch: tempfile::TempDir,
- server_key: PathBuf,
- _hooks: tempfile::TempDir,
-}
-
-impl Server {
- fn start() -> Self {
- let data = tempfile::tempdir().unwrap();
- let scratch = tempfile::tempdir().unwrap();
- let hooks = tempfile::tempdir().unwrap();
- let hook = hooks.path().join("pre-receive");
- std::fs::write(&hook, format!("#!/bin/sh\nexec \"{BIN}\" pre-receive\n")).unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
- }
- let server_key = keygen(scratch.path(), "web-server");
-
- let port = free_port();
- let child = Command::new(BIN)
- .args(["--port", &port.to_string()])
- .arg("--data-dir")
- .arg(data.path())
- .args(["--cert-nonce-seed", "test-seed"])
- .arg("--hooks-dir")
- .arg(hooks.path())
- .arg("--web-signing-key")
- .arg(&server_key)
- .spawn()
- .unwrap();
- wait_for_port(port);
- Self {
- child,
- port,
- data,
- scratch,
- server_key,
- _hooks: hooks,
- }
- }
-
- fn scratch(&self) -> &Path {
- self.scratch.path()
- }
-
- /// Create a bare repo by pushing an initial commit to it (auto-init).
- fn create_repo(&self, name: &str) -> PathBuf {
- let work = self.scratch.path().join(format!("work-{name}"));
- std::fs::create_dir_all(&work).unwrap();
- run(&work, "git", &["init", "-q", "-b", "main"]);
- std::fs::write(work.join("README.md"), "hello\n").unwrap();
- run(&work, "git", &["add", "."]);
- run(
- &work,
- "git",
- &["-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"],
- );
- let url = format!("http://127.0.0.1:{}/{name}", self.port);
- run(&work, "git", &["push", "-q", &url, "main"]);
- self.data.path().join(name)
- }
-
- /// Add the server's own key as a member, so it may sign web edits.
- fn add_server_member(&self, bare: &Path) {
- self.add_member(bare, "web-server", &pubkey(&self.server_key));
- }
-
- /// Sign in with `key` via the challenge flow, returning the session cookie.
- fn sign_in(&self, key: &Path) -> String {
- let nonce = self.get("/login", "").field("nonce").unwrap();
- let signature = sign_nonce(key, &nonce);
- let response = self.post(
- "/login",
- "",
- &form(&[
- ("nonce", &nonce),
- ("public_key", &pubkey(key)),
- ("signature", &signature),
- ]),
- );
- assert_eq!(
- response.status, 303,
- "sign-in should redirect: {}",
- response.body
- );
- response.session_cookie().unwrap()
- }
-
- /// Write a member ref `refs/meta/member/<username>` into `bare` directly, in
- /// the on-disk layout the loader reads.
- fn add_member(&self, bare: &Path, username: &str, public_key: &str) {
- let principal = hash_object(bare, username.as_bytes());
- let key_blob = hash_object(bare, public_key.as_bytes());
- let keys = mktree(bare, &format!("100644 blob {key_blob}\tkey\n"));
- let trust = mktree(bare, &format!("040000 tree {keys}\tKeys\n"));
- let empty = mktree(bare, "");
- let root = mktree(
- bare,
- &format!(
- "100644 blob {principal}\tprincipal\n\
- 040000 tree {empty}\tvalid_after\n\
- 040000 tree {empty}\tvalid_before\n\
- 040000 tree {trust}\ttrust\n"
- ),
- );
- let commit = git(bare, &["commit-tree", &root, "-m", "member"]).unwrap();
- git(
- bare,
- &[
- "update-ref",
- &format!("refs/meta/member/{username}"),
- &commit,
- ],
- )
- .unwrap();
- }
-
- /// Like [`Server::add_member`], but with `provenance/SelfAttestedWeb` —
- /// the shape a member self-onboarded through the browser carries, still
- /// resting on a leaf key so the challenge-response sign-in flow works.
- fn add_self_attested_member(&self, bare: &Path, username: &str, public_key: &str) {
- let principal = hash_object(bare, username.as_bytes());
- let key_blob = hash_object(bare, public_key.as_bytes());
- let keys = mktree(bare, &format!("100644 blob {key_blob}\tkey\n"));
- let trust = mktree(bare, &format!("040000 tree {keys}\tKeys\n"));
- let empty = mktree(bare, "");
- let provenance = mktree(bare, &format!("040000 tree {empty}\tSelfAttestedWeb\n"));
- let root = mktree(
- bare,
- &format!(
- "100644 blob {principal}\tprincipal\n\
- 040000 tree {empty}\tvalid_after\n\
- 040000 tree {empty}\tvalid_before\n\
- 040000 tree {trust}\ttrust\n\
- 040000 tree {provenance}\tprovenance\n"
- ),
- );
- let commit = git(bare, &["commit-tree", &root, "-m", "member"]).unwrap();
- git(
- bare,
- &[
- "update-ref",
- &format!("refs/meta/member/{username}"),
- &commit,
- ],
- )
- .unwrap();
- }
-
- fn get(&self, path: &str, cookie: &str) -> Http {
- let headers: Vec<(&str, &str)> = if cookie.is_empty() {
- vec![]
- } else {
- vec![("Cookie", cookie)]
- };
- self.request("GET", path, &headers, "")
- }
-
- fn post(&self, path: &str, cookie: &str, body: &str) -> Http {
- let mut headers = vec![("Content-Type", "application/x-www-form-urlencoded")];
- if !cookie.is_empty() {
- headers.push(("Cookie", cookie));
- }
- self.request("POST", path, &headers, body)
- }
-
- fn request(&self, method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Http {
- let mut request = format!("{method} {path} HTTP/1.0\r\nHost: 127.0.0.1\r\n");
- for (name, value) in headers {
- request.push_str(&format!("{name}: {value}\r\n"));
- }
- request.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
- request.push_str(body);
-
- let mut stream = TcpStream::connect(format!("127.0.0.1:{}", self.port)).unwrap();
- stream.write_all(request.as_bytes()).unwrap();
- let mut raw = Vec::new();
- stream.read_to_end(&mut raw).unwrap();
- Http::parse(&String::from_utf8_lossy(&raw))
- }
-}
-
-impl Drop for Server {
- fn drop(&mut self) {
- self.child.kill().unwrap();
- let _wait = self.child.wait();
- }
-}
-
-/// A parsed HTTP response.
-struct Http {
- status: u16,
- headers: Vec<(String, String)>,
- body: String,
-}
-
-impl Http {
- fn parse(raw: &str) -> Self {
- let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, ""));
- let mut lines = head.lines();
- let status = lines
- .next()
- .and_then(|line| line.split_whitespace().nth(1))
- .and_then(|code| code.parse().ok())
- .unwrap_or(0);
- let headers = lines
- .filter_map(|line| line.split_once(':'))
- .map(|(name, value)| (name.trim().to_lowercase(), value.trim().to_owned()))
- .collect();
- Self {
- status,
- headers,
- body: body.to_owned(),
- }
- }
-
- fn session_cookie(&self) -> Option<String> {
- self.headers
- .iter()
- .filter(|(name, _)| name == "set-cookie")
- .find_map(|(_, value)| value.split(';').next())
- .filter(|pair| pair.starts_with("ents_session="))
- .map(str::to_owned)
- }
-
- /// The value of a hidden form field rendered as `name="<field>" value="…"`.
- fn field(&self, field: &str) -> Option<String> {
- let marker = format!("name=\"{field}\" value=\"");
- let start = self.body.find(&marker)? + marker.len();
- let rest = self.body.get(start..)?;
- let end = rest.find('"')?;
- rest.get(..end).map(str::to_owned)
- }
-}
-
-fn free_port() -> u16 {
- let probe = TcpListener::bind("127.0.0.1:0").unwrap();
- let port = probe.local_addr().unwrap().port();
- drop(probe);
- port
-}
-
-fn wait_for_port(port: u16) {
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
- loop {
- match TcpStream::connect(format!("127.0.0.1:{port}")) {
- Ok(_) => return,
- Err(_) if std::time::Instant::now() < deadline => {
- std::thread::sleep(std::time::Duration::from_millis(10));
- }
- Err(e) => panic!("server never accepted connections: {e}"),
- }
- }
-}
-
-/// Generate an ed25519 keypair at `base/<name>`, returning the private key path.
-fn keygen(base: &Path, name: &str) -> PathBuf {
- let key = base.join(name);
- let status = Command::new("ssh-keygen")
- .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"])
- .arg(&key)
- .status()
- .unwrap();
- assert!(status.success(), "ssh-keygen failed");
- key
-}
-
-/// Sign `nonce` under the login namespace with `key`, returning the SSHSIG.
-fn sign_nonce(key: &Path, nonce: &str) -> String {
- let mut child = Command::new("ssh-keygen")
- .args(["-Y", "sign", "-n", LOGIN_NAMESPACE, "-f"])
- .arg(key)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .unwrap();
- child
- .stdin
- .take()
- .unwrap()
- .write_all(nonce.as_bytes())
- .unwrap();
- let output = child.wait_with_output().unwrap();
- assert!(
- output.status.success(),
- "ssh-keygen -Y sign failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- String::from_utf8_lossy(&output.stdout).into_owned()
-}
-
-fn pubkey(private: &Path) -> String {
- std::fs::read_to_string(private.with_extension("pub"))
- .unwrap()
- .trim()
- .to_owned()
-}
-
-/// Encode form fields as `application/x-www-form-urlencoded`.
-fn form(fields: &[(&str, &str)]) -> String {
- fields
- .iter()
- .map(|(key, value)| format!("{key}={}", encode(value)))
- .collect::<Vec<_>>()
- .join("&")
-}
-
-/// Percent-encode one form value, leaving only the unreserved set unescaped.
-fn encode(value: &str) -> String {
- value
- .bytes()
- .map(|byte| {
- if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
- (byte as char).to_string()
- } else {
- format!("%{byte:02X}")
- }
- })
- .collect()
-}
-
-fn run(dir: &Path, program: &str, args: &[&str]) {
- let output = Command::new(program)
- .current_dir(dir)
- .args(args)
- .env("GIT_CONFIG_GLOBAL", "/dev/null")
- .env("GIT_CONFIG_SYSTEM", "/dev/null")
- .env("GIT_AUTHOR_NAME", "T")
- .env("GIT_AUTHOR_EMAIL", "t@e")
- .env("GIT_COMMITTER_NAME", "T")
- .env("GIT_COMMITTER_EMAIL", "t@e")
- .output()
- .unwrap();
- assert!(
- output.status.success(),
- "{program} {args:?} failed: {}",
- String::from_utf8_lossy(&output.stderr)
- );
-}
-
-/// Run `git -C bare <args>`, returning trimmed stdout on success.
-fn git(bare: &Path, args: &[&str]) -> Option<String> {
- let output = Command::new("git")
- .arg("-C")
- .arg(bare)
- .args(args)
- .envs(identity())
- .stdin(Stdio::null())
- .output()
- .unwrap();
- output
- .status
- .success()
- .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
-}
-
-fn identity() -> [(&'static str, &'static str); 4] {
- [
- ("GIT_AUTHOR_NAME", "test"),
- ("GIT_AUTHOR_EMAIL", "test@example.com"),
- ("GIT_COMMITTER_NAME", "test"),
- ("GIT_COMMITTER_EMAIL", "test@example.com"),
- ]
-}
-
-fn hash_object(bare: &Path, bytes: &[u8]) -> String {
- pipe(bare, &["hash-object", "-w", "--stdin"], bytes)
-}
-
-fn mktree(bare: &Path, spec: &str) -> String {
- pipe(bare, &["mktree"], spec.as_bytes())
-}
-
-fn pipe(bare: &Path, args: &[&str], input: &[u8]) -> String {
- let mut child = Command::new("git")
- .arg("-C")
- .arg(bare)
- .args(args)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .spawn()
- .unwrap();
- child.stdin.take().unwrap().write_all(input).unwrap();
- let output = child.wait_with_output().unwrap();
- assert!(output.status.success(), "git {args:?} failed");
- String::from_utf8_lossy(&output.stdout).trim().to_owned()
-}
crates/git-ents/Cargo.toml
@@ -1,31 +1,0 @@
-[package]
-name = "git-ents"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-crossterm = "0.29.0"
-facet = { workspace = true }
-facet-pretty = { workspace = true }
-figue = { workspace = true }
-form_urlencoded.workspace = true
-futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] }
-git-anchor = { workspace = true }
-git-comment = { workspace = true }
-git-effect = { workspace = true }
-git-ents-core = { workspace = true }
-git-ents-server = { workspace = true }
-git-member = { workspace = true }
-git-store = { workspace = true }
-git-toolchain = { workspace = true }
-inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] }
-signal-hook = { version = "0.3", features = ["iterator"] }
-tempfile = { workspace = true }
-tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
-tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] }
-ureq = "3.3.0"
-
-[lints]
-workspace = true
crates/git-ents/src/debug_session.rs
@@ -1,138 +1,0 @@
-//! The CLI side of an interactive debug session: connect to the server's
-//! WebSocket broker (see `git-ents-server`'s `web::debug`), put this
-//! terminal into raw mode, and pump bytes between it and the remote shell
-//! until either side closes.
-
-use std::io::{Read as _, Write as _};
-
-use futures_util::{SinkExt as _, StreamExt as _};
-use tokio_tungstenite::tungstenite::Message;
-use tokio_tungstenite::tungstenite::client::IntoClientRequest;
-
-/// Open a debug session at `url`, authenticated with the session `token`
-/// stored by `git ents login`.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-pub(crate) async fn run(url: &str, token: &str) -> Result<(), String> {
- let mut request = url
- .into_client_request()
- .map_err(|error| format!("bad debug session URL: {error}"))?;
- let cookie = format!("ents_session={token}")
- .parse()
- .map_err(|_invalid| "the stored session token is not a valid cookie value".to_owned())?;
- request.headers_mut().insert("Cookie", cookie);
-
- let (stream, _response) = tokio_tungstenite::connect_async(request)
- .await
- .map_err(|error| format!("could not open the debug session: {error}"))?;
- let (mut sink, mut source) = stream.split();
-
- crossterm::terminal::enable_raw_mode()
- .map_err(|error| format!("could not set the terminal to raw mode: {error}"))?;
- let result = pump(&mut sink, &mut source).await;
- let _restored = crossterm::terminal::disable_raw_mode();
- result
-}
-
-/// Relay bytes both ways: a background thread feeds raw stdin bytes through
-/// `tx`, forwarded here to the sink, while frames from `source` are written
-/// straight to stdout. A dedicated thread reads stdin because raw terminal
-/// input has no natural way to interrupt a blocking read when the session
-/// ends from the other side.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-async fn pump<S, R>(sink: &mut S, source: &mut R) -> Result<(), String>
-where
- S: futures_util::Sink<Message> + Unpin,
- R: futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
-{
- let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
- std::thread::spawn(move || {
- let mut buf = [0u8; 1024];
- loop {
- match std::io::stdin().read(&mut buf) {
- Ok(0) | Err(_) => break,
- Ok(n) => {
- let Some(chunk) = buf.get(..n) else { break };
- if tx.send(chunk.to_vec()).is_err() {
- break;
- }
- }
- }
- }
- });
-
- let (resize_tx, mut resize_rx) = tokio::sync::mpsc::unbounded_channel::<(u16, u16)>();
- spawn_resize_watcher(resize_tx.clone());
- // Size the remote pty to match this terminal before the first byte flows,
- // rather than leaving it at the broker's default until the first resize.
- if let Ok(size) = crossterm::terminal::size() {
- let _sent = resize_tx.send(size);
- }
-
- loop {
- tokio::select! {
- input = rx.recv() => {
- match input {
- Some(bytes) => {
- if sink.send(Message::Binary(bytes.into())).await.is_err() {
- return Ok(());
- }
- }
- None => return Ok(()),
- }
- }
- resize = resize_rx.recv() => {
- if let Some((cols, rows)) = resize {
- let frame = Message::Text(format!("{cols} {rows}").into());
- if sink.send(frame).await.is_err() {
- return Ok(());
- }
- }
- }
- frame = source.next() => {
- match frame {
- Some(Ok(Message::Binary(data))) => {
- let _write = std::io::stdout().write_all(&data);
- let _flush = std::io::stdout().flush();
- }
- Some(Ok(Message::Close(_))) | None => return Ok(()),
- Some(Err(error)) => return Err(format!("debug session error: {error}")),
- _ => {}
- }
- }
- }
- }
-}
-
-/// Watch for local terminal resizes (`SIGWINCH`) on a dedicated thread,
-/// sending the new `(cols, rows)` through `tx` each time. `crossterm`'s own
-/// event reader also reports resizes, but isn't an option here — we forward
-/// raw stdin bytes rather than events it would parse and consume them from.
-///
-/// `SIGWINCH` doesn't exist outside Unix, so there's no resize forwarding
-/// on other platforms.
-#[cfg(unix)]
-fn spawn_resize_watcher(tx: tokio::sync::mpsc::UnboundedSender<(u16, u16)>) {
- let Ok(mut signals) = signal_hook::iterator::Signals::new([signal_hook::consts::SIGWINCH])
- else {
- return;
- };
- std::thread::spawn(move || {
- for _signal in signals.forever() {
- let Ok(size) = crossterm::terminal::size() else {
- continue;
- };
- if tx.send(size).is_err() {
- break;
- }
- }
- });
-}
-
-#[cfg(not(unix))]
-fn spawn_resize_watcher(_tx: tokio::sync::mpsc::UnboundedSender<(u16, u16)>) {}
crates/git-ents/src/interactive.rs
@@ -1,83 +1,0 @@
-//! Prompting for `add` commands left with unset fields.
-//!
-//! An omitted field is filled interactively when the terminal supports it,
-//! so `git ents effect add` alone walks a user through every field; a script
-//! or CI invocation without a TTY gets a clear error instead of a hang.
-
-use std::io::IsTerminal as _;
-
-// @relation(cli.interactive)
-/// Whether prompting is possible: both stdin and stdout are a terminal.
-#[must_use]
-pub fn available() -> bool {
- std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
-}
-
-/// `existing`, or a required text prompt for `message` when interactive; an
-/// error naming `message` when not, so a script never hangs on a missing
-/// argument. An empty string — whether passed explicitly or typed at the
-/// prompt — is rejected the same as a missing value.
-///
-/// ## Requirements
-///
-/// @relation(cli.interactive)
-pub fn text_or(existing: Option<String>, message: &str) -> Result<String, String> {
- if let Some(value) = existing {
- return if value.is_empty() {
- Err(format!("{message} must not be empty"))
- } else {
- Ok(value)
- };
- }
- if !available() {
- return Err(format!(
- "{message} is required (not an interactive terminal)"
- ));
- }
- let value = inquire::Text::new(message)
- .prompt()
- .map_err(|error| error.to_string())?;
- if value.is_empty() {
- return Err(format!("{message} must not be empty"));
- }
- Ok(value)
-}
-
-/// `existing`, or an optional text prompt for `message` when interactive —
-/// an empty reply is `None`. Non-interactive with no `existing` value stays
-/// `None` rather than erroring, since the field is optional.
-///
-/// ## Requirements
-///
-/// @relation(cli.interactive)
-pub fn optional_text_or(existing: Option<String>, message: &str) -> Result<Option<String>, String> {
- if existing.is_some() {
- return Ok(existing);
- }
- if !available() {
- return Ok(None);
- }
- let value = inquire::Text::new(message)
- .prompt()
- .map_err(|error| error.to_string())?;
- Ok((!value.is_empty()).then_some(value))
-}
-
-/// A `Select` prompt among `options`, run only when interactive; `default`
-/// otherwise.
-///
-/// ## Requirements
-///
-/// @relation(cli.interactive)
-pub fn select_or(message: &str, options: &[&str], default: usize) -> Result<usize, String> {
- if !available() {
- return Ok(default);
- }
- let choice = inquire::Select::new(message, options.to_vec())
- .prompt()
- .map_err(|error| error.to_string())?;
- Ok(options
- .iter()
- .position(|option| *option == choice)
- .unwrap_or(default))
-}
crates/git-ents/src/main.rs
@@ -1,2267 +1,0 @@
-//! `git ents` — the git-ents command-line porcelain.
-//!
-//! It carries `git ents members` for managing the repository members recorded
-//! one-ref-per-person at `refs/meta/member/<username>`, `git ents account` for
-//! the account identity at `refs/meta/account`, `git ents effect` for the
-//! effect set at one-ref-per-effect `refs/meta/effects/<name>`, `git ents
-//! toolchain` for the toolchains stored as git trees at
-//! `refs/meta/toolchains/<name>` (`git-toolchain`), `git ents comment` for the
-//! code comments at `refs/meta/comments/<id>`, and the client setup that
-//! produces the signed pushes the server requires. The member commands read
-//! and write a remote's set by fetching the `refs/meta/member/*` refs into the
-//! local repository, editing them through [`git_member::members`], and
-//! pushing them back.
-
-mod debug_session;
-mod interactive;
-mod registry;
-
-use std::collections::BTreeMap;
-use std::path::{Path, PathBuf};
-use std::process::{Command, ExitCode, Stdio};
-
-use facet::Facet;
-use facet_pretty::FacetPretty;
-use figue::{self as args, FigueBuiltins};
-use git_anchor::{LineRange, Projection};
-use git_comment::{COMMENTS_NS, Comment};
-use git_effect::Effect;
-use git_ents_core::account::{self, Account};
-use git_member::members::{self, MEMBER_NS, Member, Trust, member_ref};
-use git_member::revocations::{self, REVOKED_REF, Revocation};
-use git_toolchain::TOOLCHAINS_NS;
-
-/// Helpful guardians of your git trees.
-#[derive(Facet)]
-struct Cli {
- /// Remote whose refs to operate on.
- #[facet(args::named, args::short = 'r', default = "origin")]
- remote: String,
- #[facet(args::subcommand)]
- command: Top,
- #[facet(flatten)]
- builtins: FigueBuiltins,
-}
-
-/// ## Requirements
-///
-/// @relation(cli.members, cli.account-checks, cli.toolchains, cli.comments, cli.login)
-#[derive(Facet)]
-#[repr(u8)]
-enum Top {
- /// Manage the repository members at `refs/meta/member/<username>`.
- Members {
- #[facet(args::subcommand)]
- action: Action,
- },
- /// Manage this repository's account identity at `refs/meta/account`.
- Account {
- #[facet(args::subcommand)]
- action: AccountAction,
- },
- /// Manage the configured effects at `refs/meta/effects/<name>`.
- Effect {
- #[facet(args::subcommand)]
- action: EffectAction,
- },
- /// Manage the toolchains stored as git trees at
- /// `refs/meta/toolchains/<name>`.
- Toolchain {
- #[facet(args::subcommand)]
- action: ToolchainAction,
- },
- /// Comment on code: one comment per ref at `refs/meta/comments/<id>`,
- /// anchored to a blob (and optionally lines) at a commit.
- Comment {
- #[facet(args::subcommand)]
- action: CommentAction,
- },
- /// Sign in to a remote's server the same way the web UI does — sign a
- /// server-issued challenge with your key — so this machine can also open a
- /// debug session (`effect debug`).
- Login {
- /// Key to sign in with; defaults to `user.signingkey`.
- #[facet(args::named)]
- key: Option<PathBuf>,
- },
- /// Run the bundled server: serve HTTP, or (via its own subcommand) run
- /// the `pre-receive`/`post-receive` hooks. Also shipped as the standalone
- /// `git-ents-server` binary.
- Server(git_ents_server::Args),
-}
-
-/// ## Requirements
-///
-/// @relation(cli.members)
-#[derive(Facet)]
-#[repr(u8)]
-enum Action {
- /// Set this machine up to sign the pushes the server requires.
- Setup {
- /// Key to sign with; defaults to `user.signingkey`, else a new or
- /// existing `~/.ssh/id_ed25519`.
- #[facet(args::named)]
- key: Option<PathBuf>,
- /// Write to this repository's config instead of your global config.
- #[facet(args::named, default)]
- local: bool,
- },
- /// List the members on a remote.
- List,
- /// Authorize a key for a member on a remote and push the update. Prompts
- /// for any field left unset when run at an interactive terminal.
- Add {
- /// Member (username) to authorize the key under — its
- /// `refs/meta/member/<username>` ref.
- #[facet(args::positional, default)]
- username: Option<String>,
- /// Key to authorize; defaults to `user.signingkey`.
- #[facet(args::named)]
- key: Option<PathBuf>,
- /// Pin a certificate authority public key instead of leaf keys: trust
- /// any certificate it issues for the member, within the cert's
- /// validity. Conflicts with `--key`.
- #[facet(args::named, args::label = "CA_PUBKEY")]
- cert_authority: Option<PathBuf>,
- /// Trust the member only at or after this OpenSSH timestamp
- /// (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`; append `Z` for UTC).
- #[facet(args::named, args::label = "TIMESTAMP")]
- valid_after: Option<String>,
- /// Stop trusting the member after this OpenSSH timestamp; omit for trust
- /// that never lapses on its own.
- #[facet(args::named, args::label = "TIMESTAMP")]
- valid_before: Option<String>,
- /// Link this member to an account by its genesis hash (`git ents
- /// account create` prints one).
- #[facet(args::named, args::label = "GENESIS_HASH")]
- account: Option<String>,
- /// Role to gate this member's pushes by, matched against
- /// `refs/meta/config`'s role rules. Omit for no role (every ref
- /// allowed).
- #[facet(args::named)]
- role: Option<String>,
- },
- /// Remove a member, deleting its ref on a remote and pushing the update.
- Remove {
- /// Member (username) to remove — its `refs/meta/member/<username>` ref.
- #[facet(args::positional)]
- username: String,
- },
- /// Revoke a key fast: add its fingerprint to the `refs/meta/revoked` deny
- /// list so it is refused before its window expires, and push the update.
- Revoke {
- /// Fingerprint of the key to deny (as shown by `members list`).
- #[facet(args::positional)]
- fingerprint: String,
- /// Free-text reason recorded alongside the revocation.
- #[facet(args::named, default = "")]
- reason: String,
- },
- /// Lift a revocation, removing a fingerprint from the `refs/meta/revoked`
- /// deny list and pushing the update.
- Unrevoke {
- /// Fingerprint to stop denying.
- #[facet(args::positional)]
- fingerprint: String,
- },
- /// Report whether a key is a member and the client is configured.
- Check {
- /// Key to look for; defaults to `user.signingkey`.
- #[facet(args::named)]
- key: Option<PathBuf>,
- },
-}
-
-/// ## Requirements
-///
-/// @relation(cli.account-checks)
-#[derive(Facet)]
-#[repr(u8)]
-enum AccountAction {
- /// Create or update this repository's account identity and push it. The
- /// presence of `refs/meta/account` is what marks the repo as an account.
- /// Prompts for any field left unset when run at an interactive terminal.
- Create {
- /// The account username — by convention the `user/<username>` repo name.
- #[facet(args::positional, default)]
- username: Option<String>,
- /// Human-facing display name; defaults to the username.
- #[facet(args::named)]
- display_name: Option<String>,
- /// Short free-text bio.
- #[facet(args::named)]
- bio: Option<String>,
- },
-}
-
-/// ## Requirements
-///
-/// @relation(cli.account-checks)
-#[derive(Facet)]
-#[repr(u8)]
-enum EffectAction {
- /// List the effects configured on a remote.
- List,
- /// Add (or replace) an effect on a remote's set and push the update.
- /// Prompts for any field left unset when run at an interactive terminal.
- Add {
- /// Name to record the effect under (`effects/<name>`).
- #[facet(args::positional, default)]
- name: Option<String>,
- /// Command the effect runs (e.g. `cargo fmt --check`); omit for a
- /// composite effect that only aggregates its dependencies.
- #[facet(args::positional, default)]
- command: Option<String>,
- /// Sandbox image the command runs in (reserved: the Sprite sandbox
- /// does not honor an image yet, so setting one is rejected).
- #[facet(args::named)]
- image: Option<String>,
- /// Effect that must pass before this one runs (repeatable).
- #[facet(args::named, args::label = "EFFECT", default)]
- depends: Vec<String>,
- /// Toolchain (`refs/meta/toolchains/<name>`) to activate on `PATH`
- /// before the command runs (repeatable).
- #[facet(args::named, args::label = "TOOLCHAIN", default)]
- toolchains: Vec<String>,
- /// Persisted cache (`refs/meta/cache/<name>`) restored into the
- /// sandbox before the command runs and snapshotted back after; the
- /// command finds its path in `$EFFECT_CACHE_DIR`.
- #[facet(args::named, default)]
- cache: Option<String>,
- },
- /// Remove an effect from a remote's set and push the update.
- Remove {
- /// Name (`effects/<name>`) to drop.
- #[facet(args::positional)]
- name: String,
- },
- /// Open an interactive, read-write shell in `remote`'s persistent effects
- /// Sprite — the same sandbox its effect runs execute in. Requires
- /// `git ents login <remote>` first.
- Debug,
- /// Show recorded effect runs (queued/running/pass/fail/error) from
- /// `refs/meta/results/*` on a remote, newest first.
- Log,
- /// Run this repository's effects locally against `at`, identical
- /// toolchain materialization and sandbox path to a push-triggered run —
- /// the queue is skipped, nothing else differs. Runs in the local Docker
- /// sandbox by default; `--unsandboxed` runs directly on the host
- /// instead.
- Run {
- /// Name (`effects/<name>`) whose result to report.
- #[facet(args::positional)]
- name: String,
- /// Commit-ish to check (defaults to `HEAD`).
- #[facet(args::named)]
- at: Option<String>,
- /// Run directly on the host instead of in the Docker sandbox — no
- /// isolation; this used to be local execution's only mode.
- #[facet(args::named, default)]
- unsandboxed: bool,
- },
-}
-
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-#[derive(Facet)]
-#[repr(u8)]
-enum ToolchainAction {
- /// Import a local directory as toolchain `name` on a remote and push it.
- /// Prompts for any field left unset when run at an interactive terminal,
- /// unless `--from` supplies it via a recipe.
- Import {
- /// Name to record the toolchain under (`toolchains/<name>`).
- #[facet(args::positional, default)]
- name: Option<String>,
- /// Directory of executables to import, activated on `PATH` when a
- /// check requests this toolchain. Not needed with `--from`.
- #[facet(args::positional, default)]
- bin: Option<String>,
- /// Directory of source to import alongside `bin`, if any — kept for
- /// provenance, never activated on `PATH`.
- #[facet(args::named)]
- src: Option<String>,
- /// SPDX license expression covering `bin` (and `src`, if given).
- #[facet(args::named, default)]
- license: Option<String>,
- /// Semver version of the toolchain being imported.
- #[facet(args::named, default)]
- version: Option<String>,
- /// Target triple the toolchain runs on (`x86_64-unknown-linux-gnu`,
- /// ...).
- #[facet(args::named, default)]
- platform: Option<String>,
- /// Recipe to derive `bin`/`src`/`license`/`version`/`platform` from
- /// instead of supplying them by hand (`rustup`, `sccache`, `url`).
- #[facet(args::named)]
- from: Option<String>,
- /// Recipe-specific selector (for `--from rustup`, the toolchain
- /// name `rustup` itself knows, e.g. `stable`; defaults to `stable`.
- /// For `--from url`, the archive URL — required).
- #[facet(args::named)]
- spec: Option<String>,
- /// With `--from`, import the recipe's actual `bin` bytes instead of
- /// its default of pointing at the distributor's own hosted archives
- /// (see `git_toolchain::Bin::Downloaded`).
- #[facet(args::named, default)]
- embed: bool,
- /// With `--from url`: leading path segments to strip when the
- /// sandbox extracts the archive (default 1, a flat
- /// `<pkg>-<version>/…` release tarball).
- #[facet(args::named, default)]
- strip: Option<u8>,
- /// With `--from url`: subdirectory of the toolchain to extract the
- /// archive into (default `bin`, putting a flat archive's payload on
- /// `PATH`).
- #[facet(args::named, default)]
- dest: Option<String>,
- },
- /// List the toolchains configured on a remote.
- List,
- /// List the recipes `--from` accepts.
- Recipes,
- /// Show a remote's toolchain `name`'s past imports, newest first: when,
- /// what recipe (if any) produced it, and its version — the ref's own
- /// commit log, not a separate audit trail.
- Log {
- /// Name (`toolchains/<name>`) to show import history for.
- #[facet(args::positional)]
- name: String,
- },
- /// Export a remote's toolchain `name` to a local directory. Read-only:
- /// fetches the toolchain's tree but never pushes.
- Export {
- /// Name (`toolchains/<name>`) to export.
- #[facet(args::positional)]
- name: String,
- /// Destination directory (created if absent; must be empty if it
- /// already exists).
- #[facet(args::positional)]
- dest: String,
- },
- /// Remove a toolchain from a remote's set and push the update.
- Remove {
- /// Name (`toolchains/<name>`) to drop.
- #[facet(args::positional)]
- name: String,
- },
- /// Bake a remote's toolchain `name` into the WS8 baked-tier directory
- /// layout at `dest`, then push a record of the manifest hash it was
- /// baked for (`docs/scale-out.adoc`, "WS8 — Hydration and toolchains").
- /// Assembling an actual machine image from `dest` is deploy-time work
- /// outside this command's scope.
- Bake {
- /// Name (`toolchains/<name>`) to bake.
- #[facet(args::positional)]
- name: String,
- /// Destination directory for the baked-tier layout (created if
- /// absent; must be empty if it already exists).
- #[facet(args::positional)]
- dest: String,
- },
- /// Show a remote's toolchain `name`: its recipe/version/platform
- /// provenance and its on-disk footprint (`bin`/`src` byte sizes).
- View {
- /// Name (`toolchains/<name>`) to view.
- #[facet(args::positional)]
- name: String,
- },
-}
-
-/// ## Requirements
-///
-/// @relation(cli.comments)
-#[derive(Facet)]
-#[repr(u8)]
-enum CommentAction {
- /// Anchor a comment to a file at a revision and push it. Prompts for the
- /// path and body when left unset at an interactive terminal.
- Add {
- /// Repository-relative path of the file the comment anchors to.
- #[facet(args::positional, default)]
- path: Option<String>,
- /// The comment's body text.
- #[facet(args::named)]
- body: Option<String>,
- /// Lines to anchor, as `<start>[:<end>]` (1-based, inclusive); omit
- /// for a whole-file comment.
- #[facet(args::named)]
- lines: Option<String>,
- /// Revision to anchor against.
- #[facet(args::named, default = "HEAD")]
- rev: String,
- /// Genesis id of the issue the comment belongs to.
- #[facet(args::named)]
- issue: Option<String>,
- /// Genesis id of the comment this one replies to.
- #[facet(args::named)]
- reply_to: Option<String>,
- },
- /// List the comments on a remote, each projected onto a revision.
- List {
- /// Revision to project each comment's anchor onto.
- #[facet(args::named, default = "HEAD")]
- rev: String,
- },
- /// Show one comment: author, anchor, projection, anchored text, and body.
- Show {
- /// The comment's id (or a unique prefix of it).
- #[facet(args::positional)]
- id: String,
- /// Revision to project the comment's anchor onto.
- #[facet(args::named, default = "HEAD")]
- rev: String,
- },
- /// Remove a comment, deleting its ref on a remote.
- Remove {
- /// The comment's id (or a unique prefix of it).
- #[facet(args::positional)]
- id: String,
- },
-}
-
-fn main() -> ExitCode {
- let config = match figue::builder::<Cli>() {
- Ok(builder) => builder,
- Err(error) => {
- eprintln!("{error}");
- return ExitCode::FAILURE;
- }
- }
- .cli(|cli| cli.args(std::env::args().skip(1)))
- .help(|help| {
- help.program_name("git-ents")
- .version(env!("CARGO_PKG_VERSION"))
- })
- .build();
- let cli: Cli = match figue::Driver::new(config).run().into_result() {
- Ok(output) => output.get(),
- Err(figue::DriverError::Help {
- text,
- suggestion: suggestion @ Some(_),
- }) => {
- println!("{text}");
- if let Some(s) = suggestion {
- println!("{}", s.render_pretty());
- }
- return ExitCode::FAILURE;
- }
- Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(),
- };
- let remote = cli.remote;
- match cli.command {
- Top::Members { action } => exit_code(run_members(action, &remote)),
- Top::Account { action } => exit_code(run_account(action, &remote)),
- Top::Effect { action } => exit_code(run_effect(action, &remote)),
- Top::Toolchain { action } => exit_code(run_toolchain(action, &remote)),
- Top::Comment { action } => exit_code(run_comment(action, &remote)),
- Top::Login { key } => exit_code(login(&remote, key.as_deref())),
- Top::Server(args) => git_ents_server::run(args),
- }
-}
-
-/// Translate a porcelain command's result into a process exit code, printing
-/// an error to stderr on failure.
-fn exit_code(result: Result<(), String>) -> ExitCode {
- match result {
- Ok(()) => ExitCode::SUCCESS,
- Err(message) => {
- eprintln!("error: {message}");
- ExitCode::FAILURE
- }
- }
-}
-
-/// ## Requirements
-///
-/// @relation(cli.members)
-fn run_members(action: Action, remote: &str) -> Result<(), String> {
- match action {
- Action::Setup { key, local } => setup(key.as_deref(), local),
- Action::List => members_list(remote),
- Action::Add {
- username,
- key,
- cert_authority,
- valid_after,
- valid_before,
- account,
- role,
- } => members_add(
- username,
- remote,
- key,
- cert_authority,
- valid_after,
- valid_before,
- account,
- role,
- ),
- Action::Remove { username } => members_remove(&username, remote),
- Action::Revoke {
- fingerprint,
- reason,
- } => members_revoke(&fingerprint, remote, reason),
- Action::Unrevoke { fingerprint } => members_unrevoke(&fingerprint, remote),
- Action::Check { key } => check(remote, key.as_deref()),
- }
-}
-
-/// ## Requirements
-///
-/// @relation(cli.account-checks)
-fn run_account(action: AccountAction, remote: &str) -> Result<(), String> {
- match action {
- AccountAction::Create {
- username,
- display_name,
- bio,
- } => account_create(username, remote, display_name, bio),
- }
-}
-
-/// ## Requirements
-///
-/// @relation(cli.account-checks)
-fn run_effect(action: EffectAction, remote: &str) -> Result<(), String> {
- match action {
- EffectAction::List => effect_list(remote),
- EffectAction::Add {
- name,
- command,
- image,
- depends,
- toolchains,
- cache,
- } => effect_add(name, command, image, depends, toolchains, cache, remote),
- EffectAction::Remove { name } => effect_remove(&name, remote),
- EffectAction::Debug => effect_debug(remote),
- EffectAction::Log => effect_log(remote),
- EffectAction::Run {
- name,
- at,
- unsandboxed,
- } => effect_run(&name, at.as_deref(), unsandboxed),
- }
-}
-
-/// Run this repository's effects locally against `at` (default `HEAD`),
-/// printing `name`'s settled outcome — the local execution path: identical
-/// toolchain materialization and sandbox as a push, minus the queue. Runs in
-/// the local Docker sandbox by default; `unsandboxed` runs directly on the
-/// host instead.
-///
-/// ## Requirements
-///
-/// @relation(cli.account-checks, checks.sandbox)
-fn effect_run(name: &str, at: Option<&str>, unsandboxed: bool) -> Result<(), String> {
- let repo = repo()?;
- let rev = at.unwrap_or("HEAD");
- let commit = git_capture(&["-C", &repo.to_string_lossy(), "rev-parse", "--verify", rev])?;
-
- let kind = if unsandboxed {
- git_effect::engine::BackendKind::Host
- } else {
- git_effect::engine::BackendKind::Docker
- };
- let live = git_effect::engine::new_live_registry();
- let outcomes = git_effect::engine::run_effect_at(&repo, commit.trim(), kind, &live)
- .map_err(|e| format!("effects: {e}"))?;
- let outcome = outcomes
- .iter()
- .find(|outcome| outcome.name == name)
- .ok_or_else(|| format!("no effect named {name} is configured"))?;
- println!("{}: {}", outcome.name, outcome.status);
- match outcome.status {
- git_effect::Status::Fail | git_effect::Status::Error => {
- Err(format!("effect {name} did not pass"))
- }
- _ => Ok(()),
- }
-}
-
-/// Print the latest recorded status of every checked commit on `remote`,
-/// newest commit first, as `<commit> <when> <effect>=<status> …`.
-fn effect_log(remote: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, git_effect::RESULTS_NS)?;
- let commits = git_effect::runs(&repo).map_err(|error| error.to_string())?;
- if commits.is_empty() {
- println!("no effect runs on {remote}");
- return Ok(());
- }
- for commit_runs in commits {
- let Some(run) = commit_runs.runs.first() else {
- continue;
- };
- let when = ago(run.at);
- let results = run
- .results
- .iter()
- .map(|outcome| format!("{}={}", outcome.name, outcome.status))
- .collect::<Vec<_>>()
- .join(" ");
- println!(
- "{} {when} {results}",
- short_id(&commit_runs.commit.to_string())
- );
- }
- Ok(())
-}
-
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-fn run_toolchain(action: ToolchainAction, remote: &str) -> Result<(), String> {
- match action {
- ToolchainAction::Import {
- name,
- bin,
- src,
- license,
- version,
- platform,
- from,
- spec,
- embed,
- strip,
- dest,
- } => toolchain_import(
- name, bin, src, license, version, platform, from, spec, embed, strip, dest, remote,
- ),
- ToolchainAction::List => toolchain_list(remote),
- ToolchainAction::Recipes => toolchain_recipes(),
- ToolchainAction::Log { name } => toolchain_log(&name, remote),
- ToolchainAction::Export { name, dest } => toolchain_export(&name, &dest, remote),
- ToolchainAction::Remove { name } => toolchain_remove(&name, remote),
- ToolchainAction::Bake { name, dest } => toolchain_bake(&name, &dest, remote),
- ToolchainAction::View { name } => toolchain_view(&name, remote),
- }
-}
-
-/// Import `bin`'s (and, optionally, `src`'s) contents as toolchain `name` on
-/// `remote` and push it. Prompts for any field left unset when run at an
-/// interactive terminal, unless `from` names a recipe (`registry::resolve`)
-/// to derive `bin`/`src`/`license`/`version`/`platform` from instead;
-/// explicit flags still win over a recipe's values, and `platform`
-/// additionally parameterizes the recipe's own resolution — pinning another
-/// platform's hosted archives without this machine ever holding its
-/// binaries.
-///
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-#[expect(clippy::too_many_arguments, reason = "one flag per import field")]
-fn toolchain_import(
- name: Option<String>,
- bin: Option<String>,
- src: Option<String>,
- license: Option<String>,
- version: Option<String>,
- platform: Option<String>,
- from: Option<String>,
- spec: Option<String>,
- embed: bool,
- strip: Option<u8>,
- dest: Option<String>,
- remote: &str,
-) -> Result<(), String> {
- let name = interactive::text_or(name, "Toolchain name")?;
-
- if from.as_deref() == Some("url") && spec.is_none() {
- return Err("the url recipe needs --spec <archive-url>".to_owned());
- }
- let recipe_desc = from
- .as_deref()
- .map(|from| registry::describe(from, spec.as_deref().unwrap_or("stable")));
- let opts = registry::RecipeOptions {
- embed,
- platform: platform.clone(),
- strip,
- dest,
- };
- let recipe = from
- .map(|recipe| registry::resolve(&recipe, spec.as_deref().unwrap_or("stable"), &opts))
- .transpose()?;
-
- let bin_plan = match bin {
- Some(bin) => registry::Bin::Dir(PathBuf::from(bin)),
- None => match recipe.as_ref().map(|r| r.bin.clone()) {
- Some(bin) => bin,
- None => registry::Bin::Dir(PathBuf::from(interactive::text_or(
- None,
- "Directory of executables to import",
- )?)),
- },
- };
- let src = src.or_else(|| {
- recipe
- .as_ref()
- .and_then(|r| r.src.as_ref().map(|s| s.display().to_string()))
- });
- let src = if src.is_some() {
- src
- } else {
- interactive::optional_text_or(None, "Directory of source to import (optional)")?
- };
- let license = match license.or_else(|| recipe.as_ref().and_then(|r| r.license.clone())) {
- Some(license) => license,
- None => interactive::text_or(None, "License (SPDX expression)")?,
- };
- let version = match version.or_else(|| recipe.as_ref().and_then(|r| r.version.clone())) {
- Some(version) => version,
- None => interactive::text_or(None, "Version (semver)")?,
- };
- let platform = match platform.or_else(|| recipe.as_ref().and_then(|r| r.platform.clone())) {
- Some(platform) => platform,
- None => interactive::text_or(None, "Platform (target triple)")?,
- };
-
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- let expected = sync(remote, &refname)?;
- let repo = repo()?;
- match bin_plan {
- registry::Bin::Dir(bin) => git_toolchain::import(
- &repo,
- &name,
- &bin,
- src.as_deref().map(Path::new),
- &license,
- &version,
- &platform,
- recipe_desc.as_deref(),
- ),
- registry::Bin::Components(components) => git_toolchain::import_downloaded(
- &repo,
- &name,
- components,
- src.as_deref().map(Path::new),
- &license,
- &version,
- &platform,
- recipe_desc.as_deref(),
- ),
- }
- .map_err(|error| error.to_string())?;
- push_signed(remote, &refname, expected.as_deref())?;
- println!("imported toolchain {name}");
- Ok(())
-}
-
-// @relation(cli.toolchains)
-/// Print every toolchain configured on `remote` as
-/// `<name> <bin> <version> <platform> <license>`.
-fn toolchain_list(remote: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, TOOLCHAINS_NS)?;
- let toolchains = git_toolchain::list(&repo).map_err(|error| error.to_string())?;
- if toolchains.is_empty() {
- println!("no toolchains configured on {remote}");
- return Ok(());
- }
- for (name, toolchain) in toolchains {
- let bin = match &toolchain.bin {
- git_toolchain::Bin::Embedded(tree) => short_id(&tree.oid().to_string()).to_owned(),
- git_toolchain::Bin::Downloaded(components) => {
- format!("{} components", components.len())
- }
- };
- let recipe = toolchain.recipe.as_deref().unwrap_or("hand-supplied");
- println!(
- "{name} {bin} {} {} {} {recipe}",
- toolchain.version, toolchain.platform, toolchain.license
- );
- }
- Ok(())
-}
-
-/// Print every recipe `git ents toolchain import --from` accepts.
-fn toolchain_recipes() -> Result<(), String> {
- let printer = facet_pretty::PrettyPrinter::new().with_doc_comments(true);
- for recipe in registry::RECIPES {
- println!("{}", recipe.pretty_with(printer.clone()));
- }
- Ok(())
-}
-
-/// Print `name`'s import history on `remote`, newest first: when, its
-/// version, and the recipe (if any) that produced it — read from
-/// `refs/meta/toolchains/<name>`'s own commit log via
-/// `git_toolchain::history`, not a separate audit trail.
-fn toolchain_log(name: &str, remote: &str) -> Result<(), String> {
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?;
- let repo = repo()?;
- let history = git_toolchain::history(&repo, name).map_err(|error| error.to_string())?;
- for (seconds, toolchain) in history {
- let when = ago(seconds);
- let recipe = toolchain.recipe.as_deref().unwrap_or("hand-supplied");
- println!("{when} {} {recipe}", toolchain.version);
- }
- Ok(())
-}
-
-// @relation(cli.toolchains, cli.remote-admin)
-/// Export `remote`'s toolchain `name` to `dest`. Read-only per
-/// `cli.remote-admin`: fetches the toolchain's tree but never pushes.
-fn toolchain_export(name: &str, dest: &str, remote: &str) -> Result<(), String> {
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?;
- let repo = repo()?;
- let toolchain =
- git_toolchain::export(&repo, name, Path::new(dest)).map_err(|error| error.to_string())?;
- println!(
- "exported toolchain {name} to {dest} (version: {}, platform: {}, license: {})",
- toolchain.version, toolchain.platform, toolchain.license
- );
- Ok(())
-}
-
-/// Show `remote`'s toolchain `name`: its recipe/version/platform provenance
-/// and its on-disk footprint, computed by walking the git trees backing
-/// `bin`/`src` (see `git_toolchain::disk_usage`).
-fn toolchain_view(name: &str, remote: &str) -> Result<(), String> {
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?;
- let repo = repo()?;
- let toolchain = git_toolchain::resolve(&repo, name).map_err(|error| error.to_string())?;
- let usage = git_toolchain::disk_usage(&repo, name).map_err(|error| error.to_string())?;
- let recipe = toolchain.recipe.as_deref().unwrap_or("hand-supplied");
- println!(
- "{name} {} {} {} {recipe}",
- toolchain.version, toolchain.platform, toolchain.license
- );
- let printer = facet_pretty::PrettyPrinter::new().with_doc_comments(true);
- println!("{}", usage.pretty_with(printer));
- Ok(())
-}
-
-// @relation(cli.toolchains)
-/// Remove toolchain `name` on `remote`, deleting its ref and pushing the
-/// update.
-fn toolchain_remove(name: &str, remote: &str) -> Result<(), String> {
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- let expected =
- sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?;
- push_delete(remote, &refname, &expected)?;
- println!("removed toolchain {name}");
- Ok(())
-}
-
-// @relation(cli.toolchains, cli.remote-admin)
-/// Bake `remote`'s toolchain `name` into the WS8 baked-tier directory
-/// layout at `dest` (`docs/scale-out.adoc`, "WS8 — Hydration and
-/// toolchains"): materializes the layout locally, records the manifest
-/// hash it was baked for, then pushes that record signed — the same
-/// attested-push path any other CLI write takes, so the baked tier is
-/// never a hole in the trust story. Assembling an actual machine image
-/// from `dest` is deploy-time infrastructure outside this command's scope.
-fn toolchain_bake(name: &str, dest: &str, remote: &str) -> Result<(), String> {
- let refname = format!("{TOOLCHAINS_NS}/{name}");
- sync(remote, &refname)?.ok_or_else(|| format!("no toolchain {name} on {remote}"))?;
- let baked_refname = git_toolchain::bake::baked_ref(name);
- let expected = sync(remote, &baked_refname)?;
-
- let repo = repo()?;
- let manifest = git_toolchain::bake::bake(&repo, name, Path::new(dest))
- .map_err(|error| error.to_string())?;
- git_toolchain::bake::record(&repo, name, manifest).map_err(|error| error.to_string())?;
- push_signed(remote, &baked_refname, expected.as_deref())?;
- println!("baked toolchain {name} to {dest} (manifest {manifest})");
- Ok(())
-}
-
-/// ## Requirements
-///
-/// @relation(cli.comments)
-fn run_comment(action: CommentAction, remote: &str) -> Result<(), String> {
- match action {
- CommentAction::Add {
- path,
- body,
- lines,
- rev,
- issue,
- reply_to,
- } => comment_add(path, body, lines.as_deref(), &rev, issue, reply_to, remote),
- CommentAction::List { rev } => comment_list(remote, &rev),
- CommentAction::Show { id, rev } => comment_show(&id, remote, &rev),
- CommentAction::Remove { id } => comment_remove(&id, remote),
- }
-}
-
-/// Anchor a comment to `path` (and optionally `lines`) as it exists at `rev`,
-/// record it at `refs/meta/comments/<id>` authored as the configured git
-/// identity, and push it. Prompts for the path and body left `None` when run
-/// at an interactive terminal.
-///
-/// ## Requirements
-///
-/// @relation(cli.comments)
-fn comment_add(
- path: Option<String>,
- body: Option<String>,
- lines: Option<&str>,
- rev: &str,
- issue: Option<String>,
- reply_to: Option<String>,
- remote: &str,
-) -> Result<(), String> {
- let path = interactive::text_or(path, "File path")?;
- let body = interactive::text_or(body, "Comment")?;
- let lines = parse_lines(lines)?;
- let repo = repo()?;
- let anchor =
- git_anchor::capture(&repo, rev, &path, lines).map_err(|error| error.to_string())?;
- let comment = Comment {
- body,
- anchor,
- issue,
- reply_to: reply_to.clone(),
- };
- let id = git_comment::new_id(None, &comment).map_err(|error| error.to_string())?;
- let refname = format!("{COMMENTS_NS}/{id}");
- if let Some(parent_id) = &reply_to {
- sync(remote, &format!("{COMMENTS_NS}/{parent_id}"))?;
- }
- let expected = sync(remote, &refname)?;
- let name = config_get("user.name").ok_or("user.name is unset")?;
- let email = config_get("user.email").ok_or("user.email is unset")?;
- git_comment::store(&repo, &id, &comment, (&name, &email)).map_err(|error| error.to_string())?;
- push_signed(remote, &refname, expected.as_deref())?;
- println!("recorded comment {id}");
- Ok(())
-}
-
-// @relation(cli.comments, cli.remote-admin)
-/// List every comment on `remote` as `<id> <author> <location> <body>`,
-/// with each anchor projected onto `rev`.
-fn comment_list(remote: &str, rev: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, COMMENTS_NS)?;
- let comments = git_comment::list(&repo).map_err(|error| error.to_string())?;
- if comments.is_empty() {
- println!("no comments on {remote}");
- return Ok(());
- }
- for (id, comment) in comments {
- let author = git_comment::provenance(&repo, &id)
- .map_err(|error| error.to_string())?
- .map_or_else(|| "?".to_owned(), |provenance| provenance.created.name);
- let place = describe_projection(&repo, &id, &comment, rev);
- let title = comment.body.lines().next().unwrap_or_default();
- println!("{} {author} {place} {title}", short_id(&id));
- }
- Ok(())
-}
-
-// @relation(cli.comments, cli.remote-admin)
-/// Show the comment `id` (or a unique prefix): who wrote and last edited it,
-/// where it was anchored, where that sits on `rev`, the anchored text, and
-/// the body.
-fn comment_show(id: &str, remote: &str, rev: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, COMMENTS_NS)?;
- let id = resolve_comment_id(&repo, id, remote)?;
- let comment = git_comment::load(&repo, &id)
- .map_err(|error| error.to_string())?
- .ok_or_else(|| format!("no comment {id} on {remote}"))?;
- println!("comment {id}");
- if let Some(provenance) =
- git_comment::provenance(&repo, &id).map_err(|error| error.to_string())?
- {
- println!(
- "author {} <{}>",
- provenance.created.name, provenance.created.email
- );
- if provenance.updated != provenance.created {
- println!(
- "edited {} <{}>",
- provenance.updated.name, provenance.updated.email
- );
- }
- }
- println!(
- "anchor {} @ {}",
- location(&comment.anchor.path, comment.anchor.lines),
- short_id(&comment.anchor.commit.to_string())
- );
- println!(
- "on {rev}: {}",
- describe_projection(&repo, &id, &comment, rev)
- );
- if let Some(issue) = &comment.issue {
- println!("issue {issue}");
- }
- if let Some(reply_to) = &comment.reply_to {
- println!("reply {reply_to}");
- }
- if comment.anchor.lines.is_some()
- && let Ok(snippet) = git_anchor::snippet(&repo, &comment.anchor)
- {
- println!();
- for line in snippet.lines() {
- println!(" | {line}");
- }
- }
- println!();
- let rendered = git_ents_server::render::to_text(
- git_ents_server::render::DEFAULT_PROSE_MIME,
- &comment.body,
- );
- for line in rendered.lines() {
- println!(" {line}");
- }
- Ok(())
-}
-
-// @relation(cli.comments)
-/// Remove the comment `id` (or a unique prefix) on `remote`, deleting its ref
-/// and pushing the deletion.
-fn comment_remove(id: &str, remote: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, COMMENTS_NS)?;
- let id = resolve_comment_id(&repo, id, remote)?;
- let refname = format!("{COMMENTS_NS}/{id}");
- let expected = sync(remote, &refname)?.ok_or_else(|| format!("no comment {id} on {remote}"))?;
- push_delete(remote, &refname, &expected)?;
- println!("removed comment {}", short_id(&id));
- Ok(())
-}
-
-/// Parse `--lines` as `<start>[:<end>]`, 1-based inclusive; a bare `<start>`
-/// anchors that single line.
-fn parse_lines(lines: Option<&str>) -> Result<Option<LineRange>, String> {
- let Some(lines) = lines else {
- return Ok(None);
- };
- let (start, end) = lines.split_once(':').unwrap_or((lines, lines));
- let parse = |number: &str| {
- number
- .trim()
- .parse::<u64>()
- .map_err(|_error| format!("invalid line number {number:?} in --lines"))
- };
- let start = parse(start)?;
- let end = parse(end)?;
- if start > end {
- return Err(format!(
- "--lines {start}:{end} is inverted (start must not come after end)"
- ));
- }
- Ok(Some(LineRange { start, end }))
-}
-
-/// Resolve `id` — a full comment genesis hash or a unique prefix of one —
-/// against the synced local comment refs.
-fn resolve_comment_id(repo: &Path, id: &str, remote: &str) -> Result<String, String> {
- let all = git_comment::list(repo).map_err(|error| error.to_string())?;
- let mut matches = all
- .into_iter()
- .map(|(full, _comment)| full)
- .filter(|full| full.starts_with(id));
- let Some(first) = matches.next() else {
- return Err(format!("no comment {id} on {remote}"));
- };
- if matches.next().is_some() {
- return Err(format!("comment id {id} is ambiguous on {remote}"));
- }
- Ok(first)
-}
-
-/// `path:lines` as the CLI prints an anchored location.
-fn location(path: &str, lines: Option<LineRange>) -> String {
- match lines {
- Some(range) if range.start == range.end => format!("{path}:{}", range.start),
- Some(range) => format!("{path}:{}-{}", range.start, range.end),
- None => path.to_owned(),
- }
-}
-
-/// One-line description of where `comment` (`id`'s document) sits on `rev`.
-fn describe_projection(repo: &Path, id: &str, comment: &Comment, rev: &str) -> String {
- match git_comment::project(repo, id, rev) {
- Ok(Projection::Current) => location(&comment.anchor.path, comment.anchor.lines),
- Ok(Projection::Relocated { path, lines }) => location(&path, lines),
- Ok(Projection::Outdated { path }) => format!("{path} [outdated]"),
- Ok(Projection::FileDeleted) => format!("{} [deleted]", comment.anchor.path),
- Err(_error) => format!("{} [unresolved]", comment.anchor.path),
- }
-}
-
-/// The first 12 characters of a hex id, as listings abbreviate it.
-fn short_id(id: &str) -> &str {
- id.get(..12).unwrap_or(id)
-}
-
-/// The trailing ` (after …, before …)` annotation for a member's validity
-/// window, or `""` when unbounded — so an expiry that has been set is visible at
-/// a glance rather than hidden in the stored `allowed_signers` options.
-fn window_suffix(member: &Member) -> String {
- let mut window = Vec::new();
- if let Some(after) = &member.valid_after {
- window.push(format!("after {after}"));
- }
- if let Some(before) = &member.valid_before {
- window.push(format!("before {before}"));
- }
- if window.is_empty() {
- String::new()
- } else {
- format!(" ({})", window.join(", "))
- }
-}
-
-// @relation(cli.account-checks, cli.remote-admin)
-/// Print every effect on `remote` as `<name> <command>`.
-fn effect_list(remote: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, git_effect::EFFECTS_NS)?;
- let mut effects = git_effect::load_all(&repo).map_err(|error| error.to_string())?;
- if effects.is_empty() {
- println!("no effects configured on {remote}");
- return Ok(());
- }
- effects.sort_by(|a, b| a.name.cmp(&b.name));
- for effect in effects {
- println!("{} {}", effect.name, effect.pretty());
- }
- Ok(())
-}
-
-// @relation(cli.account-checks)
-/// Drop the effect named `name` on `remote` and push the update.
-fn effect_remove(name: &str, remote: &str) -> Result<(), String> {
- let refname = git_effect::effect_ref(name);
- let expected =
- sync(remote, &refname)?.ok_or_else(|| format!("no effect named {name} on {remote}"))?;
- push_delete(remote, &refname, &expected)?;
- println!("removed {name}");
- Ok(())
-}
-
-/// Add `name` running `command` to `remote`'s effect set, replacing any effect
-/// already recorded under that name, and push the update. Prompts for any
-/// field left unset when run at an interactive terminal. The whole set
-/// (fetched alongside `name`'s own ref) is validated as a dependency graph
-/// (`git_effect::order`) before it is stored, so a cycle or a dangling
-/// dependency never lands on the remote.
-///
-/// ## Requirements
-///
-/// @relation(cli.account-checks)
-fn effect_add(
- name: Option<String>,
- command: Option<String>,
- image: Option<String>,
- depends: Vec<String>,
- toolchains: Vec<String>,
- cache: Option<String>,
- remote: &str,
-) -> Result<(), String> {
- let name = interactive::text_or(name, "Effect name")?;
- let command = interactive::optional_text_or(command, "Command (empty for a composite)")?;
- let depends = if depends.is_empty() {
- parse_names(interactive::optional_text_or(
- None,
- "Depends on (comma-separated, empty for none)",
- )?)
- } else {
- depends
- };
- let toolchains = if toolchains.is_empty() {
- parse_names(interactive::optional_text_or(
- None,
- "Toolchains (comma-separated, empty for none)",
- )?)
- } else {
- toolchains
- };
- let repo = repo()?;
- let refname = git_effect::effect_ref(&name);
- let expected = sync(remote, &refname)?;
- sync_namespace(remote, git_effect::EFFECTS_NS)?;
- let mut effects = git_effect::load_all(&repo).map_err(|error| error.to_string())?;
- effects.retain(|effect| effect.name != name);
- let effect = Effect {
- name: name.clone(),
- command,
- image,
- depends,
- toolchains,
- cache,
- };
- effects.push(effect.clone());
- let _ordered = git_effect::order(&effects)?;
- git_effect::store(&repo, &effect).map_err(|error| error.to_string())?;
- push_signed(remote, &refname, expected.as_deref())?;
- println!("recorded effect {name}");
- Ok(())
-}
-
-/// Split an interactive comma-separated reply into names, dropping empty
-/// segments; `None` (no reply) is no names. Shared by `depends` and
-/// `toolchains`, whose interactive prompts are both a comma-separated name
-/// list.
-fn parse_names(reply: Option<String>) -> Vec<String> {
- reply
- .map(|value| {
- value
- .split(',')
- .map(|name| name.trim().to_owned())
- .filter(|name| !name.is_empty())
- .collect()
- })
- .unwrap_or_default()
-}
-
-/// Set this machine up to produce the signed pushes the server requires:
-/// ensure a signing key exists, then record the SSH signing config
-/// (SSH-format signatures, the key, and "sign when the server asks" so pushes
-/// elsewhere are untouched). Writes global config by default, since the setup
-/// is per-machine.
-///
-/// ## Requirements
-///
-/// @relation(cli.members, auth.client-setup)
-fn setup(key: Option<&Path>, local: bool) -> Result<(), String> {
- let scope = if local { "--local" } else { "--global" };
- let signing_key = match key {
- Some(path) => ensure_key(path)?,
- None => match config_get("user.signingkey") {
- Some(existing) => ensure_key(&signing_key_path(&existing))?,
- None => ensure_key(&default_key_path()?)?,
- },
- };
- set_config(scope, "gpg.format", "ssh")?;
- set_config(scope, "user.signingkey", &signing_key)?;
- set_config(scope, "push.gpgSign", "if-asked")?;
-
- let public_key = public_key(None)?;
- let fingerprint = fingerprint(&public_key)?;
- println!(
- "configured signed pushes ({} git config)",
- scope.trim_start_matches('-')
- );
- println!("signing key: {signing_key} ({fingerprint})");
- println!("authorize it on a server with `git ents members add <remote>`");
- Ok(())
-}
-
-/// Ensure a usable SSH key exists at `path`, returning the public-key path to
-/// record in `user.signingkey`. Generates an ed25519 keypair when neither the
-/// key nor its `.pub` is present; derives a missing `.pub` from the private key.
-///
-/// ## Requirements
-///
-/// @relation(cli.key-resolution)
-fn ensure_key(path: &Path) -> Result<String, String> {
- let (private, public) = key_paths(path);
- if public.exists() {
- return Ok(public.display().to_string());
- }
- if private.exists() {
- let derived = read_public_key(&private)?;
- std::fs::write(&public, format!("{derived}\n"))
- .map_err(|error| format!("could not write {}: {error}", public.display()))?;
- return Ok(public.display().to_string());
- }
- if !confirm(&format!(
- "no SSH key at {}; generate a new ed25519 keypair there?",
- private.display()
- ))? {
- return Err("setup needs a signing key; re-run with `--key` or generate one".to_owned());
- }
- generate_key(&private)?;
- Ok(public.display().to_string())
-}
-
-/// Resolve the path to ensure for a configured `user.signingkey`. A real key
-/// path (or one a `.pub` can be derived from) is used as-is; a bare key id —
-/// e.g. an openpgp fingerprint left from another signing format — is not a
-/// path, so fall back to the default SSH key location rather than generating a
-/// keypair named after it.
-fn signing_key_path(configured: &str) -> PathBuf {
- let candidate = expand_tilde(configured);
- let (private, public) = key_paths(&candidate);
- if private.exists() || public.exists() || configured.contains('/') {
- candidate
- } else {
- default_key_path().unwrap_or(candidate)
- }
-}
-
-/// Ask `question` on the terminal, returning whether it was accepted. Enter
-/// (an empty reply) accepts; a reply starting with `n` declines.
-fn confirm(question: &str) -> Result<bool, String> {
- use std::io::Write as _;
- print!("{question} [Y/n] ");
- std::io::stdout()
- .flush()
- .map_err(|error| format!("could not write prompt: {error}"))?;
- let mut reply = String::new();
- std::io::stdin()
- .read_line(&mut reply)
- .map_err(|error| format!("could not read reply: {error}"))?;
- let reply = reply.trim();
- Ok(reply.is_empty() || !reply.starts_with(['n', 'N']))
-}
-
-/// Split a key path into its private and `.pub` halves.
-fn key_paths(path: &Path) -> (PathBuf, PathBuf) {
- if path.extension().is_some_and(|extension| extension == "pub") {
- (path.with_extension(""), path.to_owned())
- } else {
- (
- path.to_owned(),
- PathBuf::from(format!("{}.pub", path.display())),
- )
- }
-}
-
-/// Generate a passphrase-less ed25519 keypair at `private` and `<private>.pub`.
-fn generate_key(private: &Path) -> Result<(), String> {
- if let Some(dir) = private.parent()
- && !dir.as_os_str().is_empty()
- {
- std::fs::create_dir_all(dir)
- .map_err(|error| format!("could not create {}: {error}", dir.display()))?;
- }
- println!("generating a new ed25519 key at {}", private.display());
- let status = Command::new("ssh-keygen")
- .arg("-t")
- .arg("ed25519")
- .arg("-N")
- .arg("")
- .arg("-C")
- .arg(host_comment())
- .arg("-f")
- .arg(private)
- .status()
- .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
- if status.success() {
- Ok(())
- } else {
- Err("ssh-keygen could not generate a key".to_owned())
- }
-}
-
-/// A `<user>@<host>` comment for a freshly generated key, best-effort.
-fn host_comment() -> String {
- let user = std::env::var("USER").unwrap_or_else(|_unset| "git-ents".to_owned());
- match Command::new("hostname").output() {
- Ok(output) if output.status.success() => {
- let host = String::from_utf8_lossy(&output.stdout);
- let host = host.trim();
- if host.is_empty() {
- user
- } else {
- format!("{user}@{host}")
- }
- }
- Ok(_) | Err(_) => user,
- }
-}
-
-/// The default signing key path, `~/.ssh/id_ed25519`.
-fn default_key_path() -> Result<PathBuf, String> {
- let home = std::env::var("HOME").map_err(|_unset| "HOME is not set".to_owned())?;
- Ok(Path::new(&home).join(".ssh").join("id_ed25519"))
-}
-
-/// List every member on `remote` — one line per authorized key, or one
-/// `cert-authority` line per pinned-CA member — as
-/// `<username>[/<fingerprint>] <label><window>`, flagging keys on the
-/// `refs/meta/revoked` deny list as `[revoked]`.
-///
-/// ## Requirements
-///
-/// @relation(cli.members, cli.remote-admin)
-fn members_list(remote: &str) -> Result<(), String> {
- let repo = repo()?;
- sync_namespace(remote, MEMBER_NS)?;
- sync(remote, REVOKED_REF)?;
- let members = members::load_all(&repo).map_err(|error| error.to_string())?;
- if members.is_empty() {
- println!("no members on {remote} (open bootstrap window)");
- return Ok(());
- }
- let revoked = revocations::fingerprints(&repo).map_err(|error| error.to_string())?;
- for member in members {
- let suffix = window_suffix(&member);
- if let Some(ca) = member.ca() {
- println!(
- "{} cert-authority {}{suffix}",
- member.principal,
- key_comment(ca)
- );
- } else {
- for (fingerprint, key) in member.keys() {
- let flag = if revoked.contains(fingerprint) {
- " [revoked]"
- } else {
- ""
- };
- println!(
- "{}/{fingerprint} {}{suffix}{flag}",
- member.principal,
- key_comment(key)
- );
- }
- }
- }
- Ok(())
-}
-
-/// Add `fingerprint` to `remote`'s `refs/meta/revoked` deny list and push the
-/// update, so the key is refused before its window would expire.
-///
-/// ## Requirements
-///
-/// @relation(cli.members)
-fn members_revoke(fingerprint: &str, remote: &str, reason: String) -> Result<(), String> {
- if !looks_like_fingerprint(fingerprint) {
- return Err(format!(
- "{fingerprint:?} does not look like a key fingerprint \
- (expected colon-hex form, e.g. aa:bb:cc:..., as `members list` prints)"
- ));
- }
- let repo = repo()?;
- // Revoking your own key fails closed against you too: if it is the last key
- // that authorizes your pushes, you cannot even push the un-revoke. Warn
- // before locking yourself out.
- // @relation(cli.members)
- if own_fingerprint().is_some_and(|own| own == fingerprint)
- && !confirm(&format!(
- "{fingerprint} is your own signing key; \
- revoking it may lock you out of {remote}. Continue?"
- ))?
- {
- return Err("revocation cancelled".to_owned());
- }
- let expected = sync(remote, REVOKED_REF)?;
- let mut revocations = revocations::load(&repo).map_err(|error| error.to_string())?;
- if let Some(existing) = revocations
- .iter_mut()
- .find(|revocation| revocation.fingerprint == fingerprint)
- {
- existing.reason = reason;
- } else {
- revocations.push(Revocation {
- fingerprint: fingerprint.to_owned(),
- reason,
- });
- }
- revocations::store(&repo, &revocations).map_err(|error| error.to_string())?;
- push_signed(remote, REVOKED_REF, expected.as_deref())?;
- println!("revoked {fingerprint}");
- Ok(())
-}
-
-/// Remove `fingerprint` from `remote`'s `refs/meta/revoked` deny list and push
-/// the update.
-///
-/// ## Requirements
-///
-/// @relation(cli.members)
-fn members_unrevoke(fingerprint: &str, remote: &str) -> Result<(), String> {
- let repo = repo()?;
- let expected = sync(remote, REVOKED_REF)?;
- let mut revocations = revocations::load(&repo).map_err(|error| error.to_string())?;
- let before = revocations.len();
- revocations.retain(|revocation| revocation.fingerprint != fingerprint);
- if revocations.len() == before {
- return Err(format!("{fingerprint} is not revoked on {remote}"));
- }
- revocations::store(&repo, &revocations).map_err(|error| error.to_string())?;
- push_signed(remote, REVOKED_REF, expected.as_deref())?;
- println!("lifted revocation of {fingerprint}");
- Ok(())
-}
-
-/// The `key`/`cert_authority` pair for [`members_add`]. Used as given when
-/// either is already set or the terminal is non-interactive, so
-/// `--key`/`--cert-authority` and scripted runs are unchanged; otherwise
-/// prompts for which kind of trust to add.
-///
-/// ## Requirements
-///
-/// @relation(cli.interactive)
-fn resolve_trust(
- key: Option<PathBuf>,
- cert_authority: Option<PathBuf>,
-) -> Result<(Option<PathBuf>, Option<PathBuf>), String> {
- if key.is_some() && cert_authority.is_some() {
- return Err("--key conflicts with --cert-authority".to_string());
- }
- if key.is_some() || cert_authority.is_some() || !interactive::available() {
- return Ok((key, cert_authority));
- }
- let choice = interactive::select_or("Trust", &["Signing key", "Certificate authority"], 0)?;
- if choice == 1 {
- let path = interactive::text_or(None, "Certificate authority public key path")?;
- Ok((None, Some(PathBuf::from(path))))
- } else {
- let path =
- interactive::optional_text_or(None, "Signing key path (blank for user.signingkey)")?;
- Ok((path.map(PathBuf::from), None))
- }
-}
-
-/// Authorize a key (or pin a CA) for the member `username` on `remote`, trusting
-/// the member within the given validity window, and push the updated member ref.
-#[expect(
- clippy::too_many_arguments,
- reason = "each argument is an independent, optional member field set from its own CLI flag"
-)]
-/// ## Requirements
-///
-/// @relation(cli.members)
-fn members_add(
- username: Option<String>,
- remote: &str,
- key: Option<PathBuf>,
- cert_authority: Option<PathBuf>,
- valid_after: Option<String>,
- valid_before: Option<String>,
- account: Option<String>,
- role: Option<String>,
-) -> Result<(), String> {
- let username = interactive::text_or(username, "Username")?;
- let (key, cert_authority) = resolve_trust(key, cert_authority)?;
- let valid_after = interactive::optional_text_or(valid_after, "Valid after (blank for none)")?;
- let valid_before =
- interactive::optional_text_or(valid_before, "Valid before (blank for none)")?;
- let account =
- interactive::optional_text_or(account, "Link to account (genesis hash, blank to skip)")?;
- if let Some(after) = &valid_after {
- validate_timestamp(after)?;
- }
- if let Some(before) = &valid_before {
- validate_timestamp(before)?;
- }
- let repo = repo()?;
- let refname = member_ref(&username);
- let expected = sync(remote, &refname)?;
- let mut member = members::load(&repo, &username)
- .map_err(|error| error.to_string())?
- .unwrap_or_else(|| Member::with_keys(username.clone(), BTreeMap::new()));
- if valid_after.is_some() {
- member.valid_after = valid_after;
- }
- if valid_before.is_some() {
- member.valid_before = valid_before;
- }
- if account.is_some() {
- member.account = account;
- }
- if role.is_some() {
- member.role = role;
- }
-
- // Pinning a CA replaces the member's trust wholesale — a member is either
- // leaf keys or a CA, never both.
- if let Some(ca_path) = cert_authority {
- let ca = read_public_key(&ca_path)?;
- member.trust = Trust::CertAuthority(ca);
- members::store(&repo, &member).map_err(|error| error.to_string())?;
- push_signed(remote, &refname, expected.as_deref())?;
- println!("pinned a certificate authority for {username}");
- return Ok(());
- }
-
- let public_key = public_key(key.as_deref())?;
- let fingerprint = fingerprint(&public_key)?;
- let keys = match &mut member.trust {
- Trust::Keys(keys) => keys,
- Trust::CertAuthority(_ca) => {
- return Err(format!(
- "{username} is pinned to a certificate authority; \
- revoke and re-add to switch to leaf keys"
- ));
- }
- Trust::WebAuthn(_keys) => {
- return Err(format!(
- "{username} is a self-attested WebAuthn member; \
- an admin must promote them before adding leaf keys"
- ));
- }
- };
- if keys
- .values()
- .any(|existing| same_key(existing, &public_key))
- {
- println!("{fingerprint} is already authorized for {username}");
- return Ok(());
- }
- keys.insert(fingerprint.clone(), public_key);
- members::store(&repo, &member).map_err(|error| error.to_string())?;
- push_signed(remote, &refname, expected.as_deref())?;
- println!("authorized {fingerprint} for {username}");
- Ok(())
-}
-
-/// Revoke the member `username` on `remote`, deleting its ref and pushing the
-/// deletion. Removal here is a plain signed delete; quorum-gated removal is a
-/// later server-side policy.
-///
-/// ## Requirements
-///
-/// @relation(cli.members)
-fn members_remove(username: &str, remote: &str) -> Result<(), String> {
- let refname = member_ref(username);
- let expected =
- sync(remote, &refname)?.ok_or_else(|| format!("no member named {username} on {remote}"))?;
- push_delete(remote, &refname, &expected)?;
- println!("revoked {username}");
- Ok(())
-}
-
-// @relation(cli.account-checks)
-/// Create or update this repository's account identity on `remote` and push it.
-fn account_create(
- username: Option<String>,
- remote: &str,
- display_name: Option<String>,
- bio: Option<String>,
-) -> Result<(), String> {
- let username = interactive::text_or(username, "Username")?;
- let display_name =
- interactive::optional_text_or(display_name, "Display name (blank to use username)")?;
- let bio = interactive::optional_text_or(bio, "Bio (blank to skip)")?.unwrap_or_default();
- let repo = repo()?;
- let expected = sync(remote, account::ACCOUNT_REF)?;
- let existing = account::load(&repo).map_err(|error| error.to_string())?;
- let is_update = existing.is_some();
- let account = Account {
- username: username.clone(),
- display_name: display_name.unwrap_or_else(|| username.clone()),
- bio,
- // Preserve the original creation time when updating an existing account.
- created_at: existing.map_or_else(now_seconds, |account| account.created_at),
- };
- account::store(&repo, &account).map_err(|error| error.to_string())?;
- push_signed(remote, account::ACCOUNT_REF, expected.as_deref())?;
- let genesis = account::genesis(&repo).map_err(|error| error.to_string())?;
- if is_update {
- println!("updated account {username}");
- } else {
- println!("created account {username}");
- }
- if let Some(genesis) = genesis {
- println!("genesis: {genesis} (pass to `members add --account` to link a member)");
- }
- Ok(())
-}
-
-/// The SSHSIG namespace a sign-in signature is made under; must match the
-/// server's `git-ents-server::web::write::LOGIN_NAMESPACE`.
-const LOGIN_NAMESPACE: &str = "git.ents.cloud";
-
-/// Sign in to `remote`'s server: fetch its one-time challenge, sign it locally
-/// with `key` (never handing the private key anywhere), and post the
-/// signature back — the same proof the browser login page collects by hand.
-/// The returned session token is stored locally so `effect_debug` can reuse
-/// it.
-///
-/// ## Requirements
-///
-/// @relation(cli.login)
-fn login(remote: &str, key: Option<&Path>) -> Result<(), String> {
- let (base, _repo_path) = remote_http_base(remote)?;
- let private_key = signing_key_file(key)?;
- let public_key = public_key(key)?;
-
- let nonce = http_get(&format!("{base}/login/cli"))?;
- let signature = sign_challenge(&private_key, &nonce)?;
- let body = form_urlencoded::Serializer::new(String::new())
- .append_pair("public_key", &public_key)
- .append_pair("signature", &signature)
- .append_pair("nonce", &nonce)
- .finish();
- let token = http_post_form(&format!("{base}/login/cli"), &body)?;
-
- store_session(&host_of(&base)?, &token)?;
- println!("signed in to {remote}");
- Ok(())
-}
-
-/// Open an interactive, read-write shell in `remote`'s persistent effects
-/// Sprite, brokered by the server over a WebSocket using the session
-/// `login` stored.
-///
-/// ## Requirements
-///
-/// @relation(checks.debug)
-fn effect_debug(remote: &str) -> Result<(), String> {
- let (base, repo_path) = remote_http_base(remote)?;
- let host = host_of(&base)?;
- let token = load_session(&host)?
- .ok_or_else(|| format!("not signed in to {remote}; run `git ents login {remote}` first"))?;
- let ws_url = format!("{}/_debug/{repo_path}", to_ws(&base));
-
- let runtime = tokio::runtime::Runtime::new()
- .map_err(|error| format!("could not start the async runtime: {error}"))?;
- runtime.block_on(crate::debug_session::run(&ws_url, &token))
-}
-
-/// The path to the private half of the signing key to use: `key` verbatim, or
-/// the path behind `user.signingkey`, resolved the same way `setup` does.
-///
-/// ## Requirements
-///
-/// @relation(cli.key-resolution)
-fn signing_key_file(key: Option<&Path>) -> Result<PathBuf, String> {
- match key {
- Some(path) => Ok(key_paths(path).0),
- None => {
- let configured = config_get("user.signingkey")
- .ok_or("no --key given and user.signingkey is unset")?;
- Ok(key_paths(&signing_key_path(&configured)).0)
- }
- }
-}
-
-/// Sign `nonce` under [`LOGIN_NAMESPACE`] with the private key at `path`,
-/// returning the armored SSH signature. `ssh-keygen -Y sign` only writes a
-/// signature next to a file it read, so the nonce is staged there first.
-///
-/// ## Requirements
-///
-/// @relation(cli.login)
-fn sign_challenge(private_key: &Path, nonce: &str) -> Result<String, String> {
- let dir = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?;
- let data = dir.path().join("nonce");
- std::fs::write(&data, nonce).map_err(|error| format!("could not write challenge: {error}"))?;
- let status = Command::new("ssh-keygen")
- .args(["-Y", "sign", "-f"])
- .arg(private_key)
- .args(["-n", LOGIN_NAMESPACE])
- .arg(&data)
- .status()
- .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
- if !status.success() {
- return Err("ssh-keygen could not sign the challenge".to_owned());
- }
- std::fs::read_to_string(dir.path().join("nonce.sig"))
- .map_err(|error| format!("could not read the signature: {error}"))
-}
-
-/// The server's http(s) base URL and repository path (without `.git`) for
-/// `remote`'s configured URL, e.g. `https://ents.example.com` and `org/repo`.
-fn remote_http_base(remote: &str) -> Result<(String, String), String> {
- let url = git_capture(&["remote", "get-url", remote])?;
- let url = url.trim();
- let (scheme, rest) = url
- .split_once("://")
- .ok_or_else(|| format!("{remote} is not an http(s) remote; login and debug need one"))?;
- if scheme != "http" && scheme != "https" {
- return Err(format!(
- "{remote} is not an http(s) remote; login and debug need one"
- ));
- }
- let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
- let repo_path = path.strip_suffix(".git").unwrap_or(path).trim_matches('/');
- Ok((format!("{scheme}://{host}"), repo_path.to_owned()))
-}
-
-/// The `host[:port]` portion of an `http(s)://host[:port]` base URL.
-fn host_of(base: &str) -> Result<String, String> {
- base.split_once("://")
- .map(|(_scheme, host)| host.to_owned())
- .ok_or_else(|| "malformed server URL".to_owned())
-}
-
-/// Rewrite an `http(s)://` base URL to its `ws(s)://` equivalent.
-fn to_ws(base: &str) -> String {
- if let Some(rest) = base.strip_prefix("https://") {
- format!("wss://{rest}")
- } else if let Some(rest) = base.strip_prefix("http://") {
- format!("ws://{rest}")
- } else {
- base.to_owned()
- }
-}
-
-/// GET `url`, returning the response body, or its body text as the error on a
-/// non-2xx status.
-fn http_get(url: &str) -> Result<String, String> {
- let mut response = ureq::get(url)
- .config()
- .http_status_as_error(false)
- .build()
- .call()
- .map_err(|error| format!("GET {url} failed: {error}"))?;
- let status = response.status();
- let text = response
- .body_mut()
- .read_to_string()
- .map_err(|error| format!("could not read the response: {error}"))?;
- if status.is_success() {
- Ok(text)
- } else if text.is_empty() {
- Err(format!("GET {url} returned {status}"))
- } else {
- Err(text)
- }
-}
-
-/// GET `url`, returning the raw response bytes — [`http_get`]'s counterpart
-/// for a binary download (an archive, ...) rather than text.
-fn http_get_bytes(url: &str) -> Result<Vec<u8>, String> {
- let mut response = ureq::get(url)
- .config()
- .http_status_as_error(false)
- .build()
- .call()
- .map_err(|error| format!("GET {url} failed: {error}"))?;
- let status = response.status();
- if !status.is_success() {
- return Err(format!("GET {url} returned {status}"));
- }
- response
- .body_mut()
- .read_to_vec()
- .map_err(|error| format!("could not read the response: {error}"))
-}
-
-/// GET `url` and return its sha256, streamed straight into the hash rather
-/// than buffered — [`http_get_bytes`]'s counterpart for a trust-on-first-use
-/// pin, which only ever needs the digest and otherwise discards the archive.
-fn http_get_sha256(url: &str) -> Result<String, String> {
- let mut response = ureq::get(url)
- .config()
- .http_status_as_error(false)
- .build()
- .call()
- .map_err(|error| format!("GET {url} failed: {error}"))?;
- let status = response.status();
- if !status.is_success() {
- return Err(format!("GET {url} returned {status}"));
- }
- git_toolchain::sha256_hex_reader(response.body_mut().as_reader())
- .map_err(|error| format!("could not hash: {error}"))
-}
-
-/// POST an `application/x-www-form-urlencoded` `body` to `url`, returning the
-/// response body, or its body text as the error on a non-2xx status.
-fn http_post_form(url: &str, body: &str) -> Result<String, String> {
- let mut response = ureq::post(url)
- .config()
- .http_status_as_error(false)
- .build()
- .header("Content-Type", "application/x-www-form-urlencoded")
- .send(body)
- .map_err(|error| format!("POST {url} failed: {error}"))?;
- let status = response.status();
- let text = response
- .body_mut()
- .read_to_string()
- .map_err(|error| format!("could not read the response: {error}"))?;
- if status.is_success() {
- Ok(text)
- } else if text.is_empty() {
- Err(format!("POST {url} returned {status}"))
- } else {
- Err(text)
- }
-}
-
-/// Where `login` stores the session token for `host`, one file per host.
-fn session_path(host: &str) -> Result<PathBuf, String> {
- let home = std::env::var("HOME").map_err(|_unset| "HOME is not set".to_owned())?;
- let sanitized: String = host
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || matches!(c, '.' | '-') {
- c
- } else {
- '_'
- }
- })
- .collect();
- Ok(Path::new(&home)
- .join(".config/git-ents/sessions")
- .join(sanitized))
-}
-
-/// Persist the session `token` for `host`, restricted to the owner.
-fn store_session(host: &str, token: &str) -> Result<(), String> {
- let path = session_path(host)?;
- if let Some(dir) = path.parent() {
- std::fs::create_dir_all(dir)
- .map_err(|error| format!("could not create {}: {error}", dir.display()))?;
- }
- std::fs::write(&path, token).map_err(|error| format!("could not write session: {error}"))?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt as _;
- let _permissions = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
- }
- Ok(())
-}
-
-/// The stored session token for `host`, if `login` has been run against it.
-fn load_session(host: &str) -> Result<Option<String>, String> {
- match std::fs::read_to_string(session_path(host)?) {
- Ok(token) => Ok(Some(token.trim().to_owned())),
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
- Err(error) => Err(format!("could not read the stored session: {error}")),
- }
-}
-
-/// This client's own signing-key fingerprint, best-effort — `None` when no key
-/// is configured or it cannot be read.
-fn own_fingerprint() -> Option<String> {
- let public_key = public_key(None).ok()?;
- fingerprint(&public_key).ok()
-}
-
-/// The current time as seconds since the Unix epoch.
-fn now_seconds() -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |elapsed| elapsed.as_secs())
-}
-
-/// `at` (seconds since the Unix epoch) as a relative "N units ago" string.
-fn ago(at: u64) -> String {
- let secs = now_seconds().saturating_sub(at);
- let mins = secs / 60;
- let hours = mins / 60;
- let days = hours / 24;
- if mins == 0 {
- "just now".to_owned()
- } else if hours == 0 {
- format!("{mins}m ago")
- } else if days == 0 {
- format!("{hours}h ago")
- } else {
- format!("{days}d ago")
- }
-}
-
-/// Fail-fast check, ahead of any network sync, that `value` is a well-formed
-/// OpenSSH `allowed_signers` timestamp — the same rule [`Member::validate`]
-/// (via [`members::store`]) checks again before the write actually lands, and
-/// which also checks the two bounds are not inverted.
-fn validate_timestamp(value: &str) -> Result<(), String> {
- if members::valid_timestamp(value) {
- Ok(())
- } else {
- Err(format!(
- "invalid timestamp {value:?}: expected YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z]"
- ))
- }
-}
-
-// @relation(cli.members, cli.remote-admin)
-/// Report whether `key` is a member on `remote` and how this client is
-/// configured.
-fn check(remote: &str, key: Option<&Path>) -> Result<(), String> {
- let repo = repo()?;
- let public_key = public_key(key)?;
- let fingerprint = fingerprint(&public_key)?;
- sync_namespace(remote, MEMBER_NS)?;
- let members = members::load_all(&repo).map_err(|error| error.to_string())?;
- if members.is_empty() {
- println!("{remote}: open bootstrap window (no members yet)");
- } else if let Some(member) = members.iter().find(|member| {
- member
- .keys()
- .iter()
- .any(|(_fp, k)| same_key(k, &public_key))
- }) {
- println!("{remote}: {fingerprint} is a member ({})", member.principal);
- } else {
- println!("{remote}: {fingerprint} is NOT a member");
- }
- println!(
- "client: gpg.format={}, user.signingkey={}, push.gpgSign={}",
- config_get("gpg.format").as_deref().unwrap_or("(unset)"),
- config_get("user.signingkey")
- .as_deref()
- .unwrap_or("(unset)"),
- config_get("push.gpgSign").as_deref().unwrap_or("(unset)"),
- );
- Ok(())
-}
-
-/// The repository to operate in: the current working directory's clone.
-fn repo() -> Result<PathBuf, String> {
- std::env::current_dir().map_err(|error| format!("cannot resolve current directory: {error}"))
-}
-
-/// Mirror `remote`'s `refname` into the local repository so the set helpers see
-/// the current value, returning the remote's current object id (or `None` when
-/// it has no such ref — for the signer set, the open bootstrap window). When the
-/// remote has none, clear any stale local ref so the set reads empty.
-///
-/// ## Requirements
-///
-/// @relation(cli.remote-admin)
-fn sync(remote: &str, refname: &str) -> Result<Option<String>, String> {
- let listing = ls_remote(remote, refname)?;
- let oid = listing.split_whitespace().next().map(str::to_owned);
- if oid.is_some() {
- let refspec = format!("+{refname}:{refname}");
- git_run(&["fetch", "--quiet", remote, &refspec])?;
- } else {
- let _deleted = git_capture(&["update-ref", "-d", refname]);
- }
- Ok(oid)
-}
-
-/// Mirror every ref under `remote`'s `namespace` (e.g. `refs/meta/member`) into
-/// the local repository, pruning local refs the remote no longer has, so the
-/// glob helpers see the remote's current set.
-///
-/// ## Requirements
-///
-/// @relation(cli.remote-admin)
-fn sync_namespace(remote: &str, namespace: &str) -> Result<(), String> {
- let refspec = format!("+{namespace}/*:{namespace}/*");
- git_run(&["fetch", "--quiet", "--prune", remote, &refspec])
-}
-
-/// Push the local `refname` to `remote`, signed per the client's config.
-///
-/// `expected` is the remote tip observed at sync time (`None` when the ref did
-/// not exist). Pushing with `--force-with-lease` pinned to that value, plus
-/// `--force-if-includes`, makes the update a clean compare-and-swap: it is
-/// rejected rather than clobbering a set someone changed since the fetch.
-///
-/// ## Requirements
-///
-/// @relation(cli.remote-admin, cli.compare-and-swap)
-fn push_signed(remote: &str, refname: &str, expected: Option<&str>) -> Result<(), String> {
- let lease = format!(
- "--force-with-lease={refname}:{}",
- expected.unwrap_or(git_ents_core::ZERO_OID)
- );
- git_run(&["push", "--force-if-includes", &lease, remote, refname])
-}
-
-/// Delete `refname` on `remote`, signed per the client's config and pinned with
-/// `--force-with-lease` to the `expected` tip so a member changed since the
-/// fetch is not clobbered.
-///
-/// ## Requirements
-///
-/// @relation(cli.remote-admin, cli.compare-and-swap)
-fn push_delete(remote: &str, refname: &str, expected: &str) -> Result<(), String> {
- let lease = format!("--force-with-lease={refname}:{expected}");
- let refspec = format!(":{refname}");
- git_run(&["push", "--force-if-includes", &lease, remote, &refspec])
-}
-
-/// Resolve the OpenSSH public key to operate on, defaulting to the key behind
-/// `user.signingkey`.
-///
-/// ## Requirements
-///
-/// @relation(cli.key-resolution)
-fn public_key(key: Option<&Path>) -> Result<String, String> {
- match key {
- Some(path) => read_public_key(path),
- None => {
- let configured = config_get("user.signingkey")
- .ok_or("no --key given and user.signingkey is unset")?;
- if let Some(inline) = configured.strip_prefix("key::") {
- return Ok(inline.trim().to_owned());
- }
- read_public_key(&expand_tilde(&configured))
- }
- }
-}
-
-/// Read an OpenSSH public key from `path`, accepting either a `.pub` file or a
-/// private key (whose public half is derived with `ssh-keygen -y`).
-///
-/// ## Requirements
-///
-/// @relation(cli.key-resolution)
-fn read_public_key(path: &Path) -> Result<String, String> {
- if let Ok(contents) = std::fs::read_to_string(path)
- && looks_like_public_key(&contents)
- {
- return Ok(contents.trim().to_owned());
- }
- let dotpub = PathBuf::from(format!("{}.pub", path.display()));
- if let Ok(contents) = std::fs::read_to_string(&dotpub)
- && looks_like_public_key(&contents)
- {
- return Ok(contents.trim().to_owned());
- }
- let output = Command::new("ssh-keygen")
- .arg("-y")
- .arg("-f")
- .arg(path)
- .output()
- .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
- if !output.status.success() {
- return Err(format!(
- "could not read a public key from {}",
- path.display()
- ));
- }
- String::from_utf8(output.stdout)
- .map(|key| key.trim().to_owned())
- .map_err(|_invalid| "ssh-keygen produced non-UTF-8 output".to_owned())
-}
-
-/// Whether `text` opens with an OpenSSH public key type token.
-fn looks_like_public_key(text: &str) -> bool {
- let head = text.trim_start();
- head.starts_with("ssh-") || head.starts_with("ecdsa-") || head.starts_with("sk-")
-}
-
-/// Expand a leading `~/` against `$HOME`.
-fn expand_tilde(value: &str) -> PathBuf {
- if let Some(rest) = value.strip_prefix("~/")
- && let Ok(home) = std::env::var("HOME")
- {
- return Path::new(&home).join(rest);
- }
- PathBuf::from(value)
-}
-
-/// The key's MD5 fingerprint in colon form (`aa:bb:…`). Colon-separated pairs
-/// are filesystem-safe, unlike the slashes in a base64 SHA256 fingerprint that
-/// would split the `members/<name>` entry into a subtree.
-///
-/// ## Requirements
-///
-/// @relation(cli.key-resolution)
-fn fingerprint(public_key: &str) -> Result<String, String> {
- let scratch =
- tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?;
- let path = scratch.path().join("key.pub");
- std::fs::write(&path, public_key).map_err(|error| format!("could not stage key: {error}"))?;
- let output = Command::new("ssh-keygen")
- .arg("-E")
- .arg("md5")
- .arg("-l")
- .arg("-f")
- .arg(&path)
- .output()
- .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
- if !output.status.success() {
- return Err("ssh-keygen could not fingerprint the key".to_owned());
- }
- let text = String::from_utf8(output.stdout)
- .map_err(|_invalid| "ssh-keygen produced non-UTF-8 output".to_owned())?;
- let field = text
- .split_whitespace()
- .nth(1)
- .ok_or("ssh-keygen returned an unexpected fingerprint line")?;
- Ok(field.strip_prefix("MD5:").unwrap_or(field).to_owned())
-}
-
-/// Whether `text` looks like an MD5 key fingerprint (`aa:bb:...`): only
-/// colon-separated two-digit hex groups, matching what `members list` prints
-/// and [`fingerprint`] produces.
-fn looks_like_fingerprint(text: &str) -> bool {
- let groups: Vec<&str> = text.split(':').collect();
- groups.len() > 1
- && groups
- .iter()
- .all(|group| group.len() == 2 && group.chars().all(|c| c.is_ascii_hexdigit()))
-}
-
-/// Whether two OpenSSH public keys share a type and body, ignoring the comment.
-fn same_key(a: &str, b: &str) -> bool {
- key_body(a) == key_body(b)
-}
-
-/// A key's `(type, base64-body)`, the part that identifies it.
-fn key_body(key: &str) -> (Option<&str>, Option<&str>) {
- let mut fields = key.split_whitespace();
- (fields.next(), fields.next())
-}
-
-/// A key's trailing comment, or an empty string when it has none.
-fn key_comment(key: &str) -> String {
- let mut fields = key.split_whitespace();
- let _type = fields.next();
- let _body = fields.next();
- fields.collect::<Vec<_>>().join(" ")
-}
-
-/// Read a git config value, treating absent or empty as unset.
-fn config_get(key: &str) -> Option<String> {
- let output = Command::new("git")
- .args(["config", "--get", key])
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
- let value = String::from_utf8(output.stdout).ok()?;
- let value = value.trim();
- if value.is_empty() {
- None
- } else {
- Some(value.to_owned())
- }
-}
-
-/// Set a git config key, failing if git does.
-fn set_config(scope: &str, key: &str, value: &str) -> Result<(), String> {
- git_run(&["config", scope, key, value])
-}
-
-/// Run git with inherited stdio, erroring on a non-zero exit.
-fn git_run(args: &[&str]) -> Result<(), String> {
- let status = Command::new("git")
- .args(args)
- .status()
- .map_err(|error| format!("failed to run git: {error}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!(
- "git {} failed",
- args.first().copied().unwrap_or("?")
- ))
- }
-}
-
-/// List `refname` on `remote`, translating git's raw "not found" fatal output
-/// into a single message in the CLI's own words rather than stacking git's
-/// `fatal: ...` lines above it; other failures fall back to git's own
-/// (trimmed) stderr.
-fn ls_remote(remote: &str, refname: &str) -> Result<String, String> {
- let output = Command::new("git")
- .args(["ls-remote", remote, refname])
- .output()
- .map_err(|error| format!("failed to run git: {error}"))?;
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("does not appear to be a git repository")
- || stderr.contains("Could not read from remote repository")
- {
- return Err(format!("remote '{remote}' not found"));
- }
- return Err(stderr.trim().to_owned());
- }
- String::from_utf8(output.stdout).map_err(|_invalid| "git produced non-UTF-8 output".to_owned())
-}
-
-/// Run git and capture its stdout (stderr inherited), erroring on a non-zero
-/// exit.
-fn git_capture(args: &[&str]) -> Result<String, String> {
- let output = Command::new("git")
- .args(args)
- .stderr(Stdio::inherit())
- .output()
- .map_err(|error| format!("failed to run git: {error}"))?;
- if !output.status.success() {
- return Err(format!(
- "git {} failed",
- args.first().copied().unwrap_or("?")
- ));
- }
- String::from_utf8(output.stdout).map_err(|_invalid| "git produced non-UTF-8 output".to_owned())
-}
crates/git-ents/src/registry.rs
@@ -1,640 +1,0 @@
-//! Recipes for `git ents toolchain import --from <recipe>`.
-//!
-//! A recipe derives `bin`/`src`/`license`/`version`/`platform` from a local
-//! toolchain install a user already has (rustup, ...) instead of requiring
-//! them to hand-supply paths and metadata `git-toolchain` itself has no way
-//! to discover. This module only locates and describes what's already on
-//! disk (or, for `bin`, what a distributor already hosts); it never installs
-//! a toolchain.
-
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-use facet::Facet;
-use git_toolchain::Component;
-use tempfile::TempDir;
-
-/// How a recipe resolved `bin`: either a local directory to import as-is (the
-/// embedded path, `--embed`), or a list of externally-hosted components to
-/// record as a [`git_toolchain::Bin::Downloaded`] manifest instead of
-/// importing local bytes.
-#[derive(Clone)]
-pub enum Bin {
- Dir(PathBuf),
- Components(Vec<Component>),
-}
-
-/// What a recipe resolved from a local toolchain install, ready to hand to
-/// `git_toolchain::import`/`import_downloaded`. A `None` metadata field is
-/// one the recipe cannot know (the `url` recipe knows nothing about what an
-/// arbitrary archive contains); the CLI's own flag-else-prompt chain covers
-/// it.
-///
-/// `_staging`, when `bin` is [`Bin::Dir`] pointing into a temporary
-/// directory, is kept alive only so the directory survives until the
-/// caller's `import()` call has read it, and is deleted on drop.
-pub struct Resolved {
- pub bin: Bin,
- pub src: Option<PathBuf>,
- pub license: Option<String>,
- pub version: Option<String>,
- pub platform: Option<String>,
- _staging: Option<TempDir>,
-}
-
-/// How [`resolve`] should resolve, beyond the recipe's own `spec` selector.
-#[derive(Default)]
-pub struct RecipeOptions {
- /// Import the recipe's actual local `bin` bytes instead of recording
- /// hosted, hash-pinned archives. Incompatible with `platform`.
- pub embed: bool,
- /// Resolve for this target triple instead of the local machine's — the
- /// recipe then never touches local binaries, only the distributor's
- /// hosted metadata and archives, so a toolchain for the effect worker's
- /// sandbox can be pinned from any machine.
- pub platform: Option<String>,
- /// `url` recipe only: leading path segments to strip at extraction
- /// (default 1, a flat `<pkg>-<version>/…` release tarball).
- pub strip: Option<u8>,
- /// `url` recipe only: subdirectory of the toolchain to extract into
- /// (default `bin`, so a flat archive's payload lands on `PATH`).
- pub dest: Option<String>,
-}
-
-/// A recipe `git ents toolchain import --from` accepts, described richly
-/// enough to render on its own via `facet_pretty` (see `git ents toolchain
-/// recipes`) rather than as a bare name.
-#[derive(Facet)]
-pub struct RecipeInfo {
- /// The name passed to `--from`.
- pub name: &'static str,
- /// What `--from-spec` selects for this recipe, e.g. a rustup channel or
- /// version name.
- pub spec: &'static str,
- /// What this recipe does, in one line.
- pub summary: &'static str,
-}
-
-/// Every recipe `resolve` knows, for `git ents toolchain recipes` and
-/// `resolve`'s own error message — a plain list rather than a trait registry,
-/// since each recipe is one function with its own selector semantics, not a
-/// uniform interface worth abstracting over for a list of one.
-pub const RECIPES: &[RecipeInfo] = &[
- RecipeInfo {
- name: "rustup",
- spec: "a channel or version, e.g. stable, nightly, 1.75.0",
- summary: "Resolves a rustup-managed toolchain via `rustc +<spec> -vV`; \
- by default points at rust-lang's own hosted, hash-pinned \
- component archives instead of importing local bytes.",
- },
- RecipeInfo {
- name: "sccache",
- spec: "a mozilla/sccache release tag, e.g. v0.8.2, or empty for latest",
- summary: "Resolves a prebuilt sccache release from GitHub; with \
- --platform it records the release archive as a hash pin \
- computed at import time (trust on first use — GitHub \
- publishes no hash manifest), otherwise it downloads this \
- machine's archive and imports the binary directly.",
- },
- RecipeInfo {
- name: "url",
- spec: "an archive URL, e.g. https://ziglang.org/download/.../zig-x86_64-linux-0.15.2.tar.xz",
- summary: "Pins any hosted archive as a downloaded toolchain: fetches \
- it once at import time only to compute its sha256 (trust \
- on first use), records url+hash+layout (--strip, --dest), \
- and lets the sandbox fetch the bytes itself. Version, \
- platform, and license must be supplied explicitly.",
- },
-];
-
-/// Resolve `recipe` against `spec` (a recipe-specific selector, e.g. a
-/// rustup toolchain name). See [`RECIPES`] for what's known.
-///
-/// `opts.embed` forces the old behavior of staging and importing `bin`'s
-/// actual bytes; by default the recipe instead points at its distributor's
-/// own hosted, hash-verified archives (see [`Bin::Components`]), sparing the
-/// repository the toolchain's own bytes. `opts.platform` resolves for a
-/// foreign target without touching local binaries at all, and is therefore
-/// rejected together with `embed`.
-///
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-pub fn resolve(recipe: &str, spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> {
- if opts.embed && opts.platform.is_some() {
- return Err(
- "--embed imports this machine's bytes; it cannot target another platform".to_owned(),
- );
- }
- if (opts.strip.is_some() || opts.dest.is_some()) && recipe != "url" {
- return Err(format!(
- "--strip/--dest are layout hints for the url recipe; {recipe} records its own layout"
- ));
- }
- match recipe {
- "rustup" => rustup(spec, opts),
- "sccache" => sccache(spec, opts.platform.as_deref()),
- "url" => url_archive(spec, opts),
- other => Err(format!(
- "unknown toolchain recipe {other:?} (known: {})",
- RECIPES
- .iter()
- .map(|recipe| recipe.name)
- .collect::<Vec<_>>()
- .join(", ")
- )),
- }
-}
-
-/// `<recipe> <spec>`, recorded as [`git_toolchain::Toolchain::recipe`] — the
-/// provenance a `--from` import leaves behind, distinct from `Resolved`
-/// itself since only the recipe name and selector (not the resolved bytes)
-/// are worth keeping once the import is written.
-pub fn describe(recipe: &str, spec: &str) -> String {
- format!("{recipe} {spec}")
-}
-
-/// Resolve a rustup-managed toolchain named `spec` (e.g. `stable`,
-/// `1.75.0`, `nightly`) via `rustc +<spec> -vV`, which reports the
-/// toolchain's own `release` (its version) and `host` (its target platform)
-/// without needing rustup's own metadata format.
-///
-/// By default `bin` is resolved as [`Bin::Components`]: the `rustc`,
-/// `cargo`, and `rust-std` entries of rust-lang's own published channel
-/// manifest for `version` (or the `nightly` channel manifest, which has no
-/// stable per-version name, when `version` is a nightly), each already
-/// hash-pinned by rust-lang. These are real rustup-installer archives: every
-/// one unpacks to `<package>-<version>-<target>/<component>/...`, so
-/// `git_toolchain::export`'s extraction strips exactly that two-segment
-/// prefix rather than needing this recipe to relocate anything.
-///
-/// With `embed`, `bin` is resolved the old way instead: a rustup sysroot's
-/// `bin/*` binaries are linked against `lib/*.dylib` (or `.so`) via an rpath
-/// relative to `bin`'s own parent (`@loader_path/../lib` on macOS,
-/// `$ORIGIN/../lib` on Linux) — but `git-toolchain` activates an embedded
-/// toolchain by extracting `bin` as-is and putting *that* directory straight
-/// on `PATH`, with no sibling `lib` beside it. Passing `sysroot/bin` alone
-/// therefore produces a `rustc` that can neither load its own shared runtime
-/// nor find its own standard library to link against. This function instead
-/// stages a self-contained directory: `sysroot/bin`'s executables copied to
-/// its top level (so `PATH` still finds them directly) plus the whole of
-/// `sysroot/lib` copied under a `lib/` subdirectory inside it, with each
-/// binary's rpath rewritten from `../lib` to `lib` so it resolves relative
-/// to wherever the toolchain ends up extracted, not relative to `bin`'s
-/// original location.
-///
-/// `src` is `<sysroot>/lib/rustlib/src/rust`, unstaged, when the `rust-src`
-/// component is installed, else omitted, regardless of `embed`. Rust's own
-/// toolchain is dual-licensed `MIT OR Apache-2.0`.
-///
-/// With `opts.platform`, no local toolchain is consulted at all: the channel
-/// manifest for `spec` (`stable`, `nightly`, a version) is the sole source —
-/// it names its own version (`[pkg.rustc].version`) and hosts hash-pinned
-/// archives for every target, so a toolchain for a foreign platform (the
-/// effect worker's sandbox) can be pinned from any machine. `src` is omitted
-/// there: there is no local sysroot to point at.
-///
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-fn rustup(spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> {
- if let Some(platform) = &opts.platform {
- let manifest = fetch_manifest(spec)?;
- let version = manifest_version(&manifest)
- .ok_or_else(|| format!("channel-rust-{spec}.toml has no [pkg.rustc].version"))?;
- return Ok(Resolved {
- bin: Bin::Components(manifest_components(&manifest, platform)?),
- src: None,
- license: Some("MIT OR Apache-2.0".to_owned()),
- version: Some(version),
- platform: Some(platform.clone()),
- _staging: None,
- });
- }
-
- let toolchain_arg = format!("+{spec}");
- let sysroot = rustc(&toolchain_arg, &["--print", "sysroot"])?;
- let sysroot = PathBuf::from(sysroot.trim());
-
- let verbose = rustc(&toolchain_arg, &["-vV"])?;
- let version = verbose_field(&verbose, "release")
- .ok_or_else(|| format!("rustc +{spec} -vV did not report a release"))?;
- let platform = verbose_field(&verbose, "host")
- .ok_or_else(|| format!("rustc +{spec} -vV did not report a host"))?;
-
- let src = sysroot.join("lib/rustlib/src/rust");
- let src = src.is_dir().then_some(src);
-
- let (bin, staging) = if opts.embed {
- let staging = tempfile::tempdir()
- .map_err(|error| format!("could not create a staging directory: {error}"))?;
- stage_bin(&sysroot.join("bin"), &sysroot.join("lib"), staging.path())?;
- (Bin::Dir(staging.path().to_owned()), Some(staging))
- } else {
- let manifest = fetch_manifest(&channel_for(&version))?;
- (
- Bin::Components(manifest_components(&manifest, &platform)?),
- None,
- )
- };
-
- Ok(Resolved {
- bin,
- src,
- license: Some("MIT OR Apache-2.0".to_owned()),
- version: Some(version),
- platform: Some(platform),
- _staging: staging,
- })
-}
-
-/// Resolve a prebuilt `sccache` release named `spec` (a GitHub release tag,
-/// e.g. `v0.8.2`), or the latest release when `spec` is empty, from
-/// `mozilla/sccache`'s GitHub releases — the archive matching this machine's
-/// own OS/architecture, the same "what's already usable here" convention
-/// [`rustup`] follows via its local `rustc`.
-///
-/// Unlike `rustup`, GitHub publishes no manifest of hashes alongside a
-/// release to pin a hosted download against. Without a `platform` override
-/// this recipe therefore imports this machine's archive's binary directly
-/// (`Bin::Dir`). With `platform`, it instead records the release archive as
-/// a downloaded component whose sha256 it computes itself, once, at import
-/// time — trust on first use: the trust decision is taken exactly here, is
-/// audited via the recipe string on the document and the ref's commit
-/// history, and every later fetch (local export, the sandbox) verifies
-/// against the pinned hash. The archive is flat
-/// (`sccache-<tag>-<target>/sccache`), hence `strip: 1, dest: "bin"`.
-///
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-fn sccache(spec: &str, platform: Option<&str>) -> Result<Resolved, String> {
- let tag = if spec.is_empty() {
- latest_sccache_tag()?
- } else {
- spec.to_owned()
- };
- let version = tag.strip_prefix('v').unwrap_or(&tag).to_owned();
- let target = match platform {
- Some(platform) => platform.to_owned(),
- None => sccache_target()?.to_owned(),
- };
- let url = format!(
- "https://github.com/mozilla/sccache/releases/download/{tag}/sccache-{tag}-{target}.tar.gz"
- );
-
- if platform.is_some() {
- let sha256 = crate::http_get_sha256(&url)?;
- return Ok(Resolved {
- bin: Bin::Components(vec![Component {
- url,
- sha256,
- strip: 1,
- dest: "bin".to_owned(),
- }]),
- src: None,
- license: Some("MPL-2.0".to_owned()),
- version: Some(version),
- platform: Some(target),
- _staging: None,
- });
- }
-
- let bytes = crate::http_get_bytes(&url)?;
- let staging = tempfile::tempdir()
- .map_err(|error| format!("could not create a staging directory: {error}"))?;
- stage_sccache(&bytes, &tag, &target, staging.path())?;
-
- Ok(Resolved {
- bin: Bin::Dir(staging.path().to_owned()),
- src: None,
- license: Some("MPL-2.0".to_owned()),
- version: Some(version),
- platform: Some(target),
- _staging: Some(staging),
- })
-}
-
-/// Pin any hosted archive as a one-component downloaded toolchain —
-/// `http_archive`, in Bazel terms. `spec` is the archive's URL; it is
-/// fetched once, here, only to compute the sha256 every later verification
-/// pins against (trust on first use, audited exactly like [`sccache`]'s
-/// pin). The layout hints come from `--strip`/`--dest` (default: a flat
-/// `<pkg>-<version>/…` tarball whose payload belongs on `PATH`). This recipe
-/// knows nothing about what the archive contains, so version, platform, and
-/// license all stay `None` for the caller to supply.
-///
-/// ## Requirements
-///
-/// @relation(cli.toolchains)
-fn url_archive(spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> {
- if spec.is_empty() {
- return Err("the url recipe needs --spec <archive-url>".to_owned());
- }
- let sha256 = crate::http_get_sha256(spec)?;
- Ok(Resolved {
- bin: Bin::Components(vec![Component {
- url: spec.to_owned(),
- sha256,
- strip: opts.strip.unwrap_or(1),
- dest: opts.dest.clone().unwrap_or_else(|| "bin".to_owned()),
- }]),
- src: None,
- license: None,
- version: None,
- platform: None,
- _staging: None,
- })
-}
-
-/// The latest `mozilla/sccache` release's tag name, from GitHub's "latest
-/// release" API.
-fn latest_sccache_tag() -> Result<String, String> {
- let body = crate::http_get("https://api.github.com/repos/mozilla/sccache/releases/latest")?;
- json_string_field(&body, "tag_name")
- .ok_or_else(|| "GitHub's latest sccache release response carried no tag_name".to_owned())
-}
-
-/// Extract `"<key>": "value"` from a flat JSON response — a hand-rolled
-/// reader for the one field this recipe needs from GitHub's release API,
-/// rather than a full JSON parser for a format this is the only caller of.
-fn json_string_field(body: &str, key: &str) -> Option<String> {
- let prefix = format!("\"{key}\": \"");
- let rest = body.split_once(&prefix)?.1;
- let end = rest.find('"')?;
- rest.get(..end).map(str::to_owned)
-}
-
-/// This machine's OS/architecture as an `mozilla/sccache` release asset
-/// name's platform segment (e.g. `x86_64-unknown-linux-musl`).
-fn sccache_target() -> Result<&'static str, String> {
- match (std::env::consts::OS, std::env::consts::ARCH) {
- ("linux", "x86_64") => Ok("x86_64-unknown-linux-musl"),
- ("linux", "aarch64") => Ok("aarch64-unknown-linux-musl"),
- ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
- ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
- (os, arch) => Err(format!(
- "the sccache recipe does not know a release asset for {os}/{arch}"
- )),
- }
-}
-
-/// Unpack `bytes` (an `sccache-<tag>-<target>.tar.gz` release archive) and
-/// copy its `sccache` binary to the top level of `staging`, executable —
-/// where `git-toolchain`'s `Bin::Embedded` extraction expects an embedded
-/// toolchain's binaries to live.
-fn stage_sccache(bytes: &[u8], tag: &str, target: &str, staging: &Path) -> Result<(), String> {
- let scratch =
- tempfile::tempdir().map_err(|error| format!("could not create a temp dir: {error}"))?;
- let archive_path = scratch.path().join("sccache.tar.gz");
- fs::write(&archive_path, bytes)
- .map_err(|error| format!("could not write the downloaded archive: {error}"))?;
- let status = Command::new("tar")
- .arg("-xzf")
- .arg(&archive_path)
- .arg("-C")
- .arg(scratch.path())
- .status()
- .map_err(|error| format!("could not run tar: {error}"))?;
- if !status.success() {
- return Err("could not extract the sccache archive".to_owned());
- }
- let binary = scratch
- .path()
- .join(format!("sccache-{tag}-{target}"))
- .join("sccache");
- let dest = staging.join("sccache");
- fs::copy(&binary, &dest)
- .map_err(|error| format!("could not copy {}: {error}", binary.display()))?;
- make_executable(&dest)
-}
-
-/// Mark `path` executable. A no-op on platforms without a permission bit for
-/// it (Windows determines executability from the file extension instead).
-#[cfg(unix)]
-fn make_executable(path: &Path) -> Result<(), String> {
- use std::os::unix::fs::PermissionsExt as _;
-
- let mut perms = fs::metadata(path)
- .map_err(|error| format!("could not read {}: {error}", path.display()))?
- .permissions();
- perms.set_mode(0o755);
- fs::set_permissions(path, perms)
- .map_err(|error| format!("could not set permissions on {}: {error}", path.display()))
-}
-
-#[cfg(windows)]
-fn make_executable(_path: &Path) -> Result<(), String> {
- Ok(())
-}
-
-/// The manifest name for a version rustc reported: nightly builds collapse
-/// to the shared `nightly` channel, since rust-lang publishes no stable
-/// per-version manifest name for them.
-fn channel_for(version: &str) -> String {
- if version.contains("nightly") {
- "nightly".to_owned()
- } else {
- version.to_owned()
- }
-}
-
-/// Fetch rust-lang's channel manifest for `channel` (`stable`, `nightly`, or
-/// a version) — the one authoritative document naming the channel's version
-/// and every target's hash-pinned component archives.
-fn fetch_manifest(channel: &str) -> Result<String, String> {
- let url = format!("https://static.rust-lang.org/dist/channel-rust-{channel}.toml");
- crate::http_get(&url)
-}
-
-/// The channel's own version, from `[pkg.rustc] version = "1.88.0 (hash
-/// date)"` — the first whitespace-separated token, valid semver for stable
-/// (`1.88.0`) and nightly (`1.90.0-nightly`) alike.
-fn manifest_version(manifest: &str) -> Option<String> {
- let raw = manifest_field(manifest, "pkg.rustc", "version")?;
- raw.split_whitespace().next().map(str::to_owned)
-}
-
-/// The three components of rust-lang's channel manifest that together make
-/// a working toolchain (compiler, cargo, and the target's standard library),
-/// resolved for `target`. Every rust-lang dist archive unpacks to
-/// `<package>-<version>-<target>/<component>/…`, hence `strip: 2` with no
-/// `dest` — the payload carries its own `bin/`/`lib/` top level.
-fn manifest_components(manifest: &str, target: &str) -> Result<Vec<Component>, String> {
- ["rustc", "cargo", "rust-std"]
- .into_iter()
- .map(|package| {
- let section = format!("pkg.{package}.target.{target}");
- let component_url = manifest_field(manifest, §ion, "url")
- .ok_or_else(|| format!("the channel manifest has no [{section}].url"))?;
- let sha256 = manifest_field(manifest, §ion, "hash")
- .ok_or_else(|| format!("the channel manifest has no [{section}].hash"))?;
- Ok(Component {
- url: component_url,
- sha256,
- strip: 2,
- dest: String::new(),
- })
- })
- .collect()
-}
-
-/// Extract `<key> = "value"` from `manifest`'s `[section]` table.
-///
-/// A hand-rolled reader for the one shape this recipe needs from rust-lang's
-/// channel manifest TOML (a flat `key = "value"` line under a `[section]`
-/// header), rather than a full TOML parser for a format this is the only
-/// caller of.
-fn manifest_field(manifest: &str, section: &str, key: &str) -> Option<String> {
- let prefix = format!("{key} = \"");
- let mut in_section = false;
- for line in manifest.lines() {
- let line = line.trim();
- if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
- in_section = name == section;
- continue;
- }
- if in_section && let Some(rest) = line.strip_prefix(&prefix) {
- return rest.strip_suffix('"').map(str::to_owned);
- }
- }
- None
-}
-
-/// Copy `bin_src`'s executables flat into `staging`, relink each one's rpath
-/// from `bin`-relative (`../lib`) to `staging`-relative (`lib`), then copy
-/// the whole of `lib_src` under `staging/lib`.
-fn stage_bin(bin_src: &Path, lib_src: &Path, staging: &Path) -> Result<(), String> {
- for entry in fs::read_dir(bin_src)
- .map_err(|error| format!("could not read {}: {error}", bin_src.display()))?
- {
- let entry =
- entry.map_err(|error| format!("could not read {}: {error}", bin_src.display()))?;
- let dest = staging.join(entry.file_name());
- fs::copy(entry.path(), &dest)
- .map_err(|error| format!("could not copy {}: {error}", entry.path().display()))?;
- make_executable(&dest)?;
- relink_rpath(&dest)?;
- }
- copy_dir_all(lib_src, &staging.join("lib"))
-}
-
-/// Rewrite a copied rustup binary's rpath so it finds its runtime libraries
-/// relative to wherever it ends up on disk (`staging/lib`, later
-/// `<extracted-toolchain>/lib`) rather than relative to its original
-/// `sysroot/bin` location. Failures are ignored: not every entry under
-/// `bin/` is a binary carrying this rpath (`rust-gdb`, `rust-lldb`, ... are
-/// shell scripts), and a tool that never dynamically links against `lib/`
-/// needs no relinking.
-fn relink_rpath(path: &Path) -> Result<(), String> {
- match std::env::consts::OS {
- "macos" => {
- drop(
- Command::new("install_name_tool")
- .args(["-rpath", "@loader_path/../lib", "@loader_path/lib"])
- .arg(path)
- .output(),
- );
- Ok(())
- }
- "linux" => {
- let output = Command::new("patchelf")
- .args(["--set-rpath", "$ORIGIN/lib"])
- .arg(path)
- .output()
- .map_err(|error| {
- format!(
- "could not run patchelf, required to relocate a rustup toolchain's \
- runtime library path on linux: {error}"
- )
- })?;
- let _ = output;
- Ok(())
- }
- other => Err(format!(
- "the rustup recipe does not know how to relocate a toolchain's runtime library \
- path on {other}"
- )),
- }
-}
-
-/// Recursively copy `src` to `dst`, preserving permissions and symlinks —
-/// `std::fs` has no directory-copy of its own.
-fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), String> {
- fs::create_dir_all(dst)
- .map_err(|error| format!("could not create {}: {error}", dst.display()))?;
- for entry in
- fs::read_dir(src).map_err(|error| format!("could not read {}: {error}", src.display()))?
- {
- let entry = entry.map_err(|error| format!("could not read {}: {error}", src.display()))?;
- let file_type = entry
- .file_type()
- .map_err(|error| format!("could not read {}: {error}", entry.path().display()))?;
- let dest_path = dst.join(entry.file_name());
- if file_type.is_dir() {
- copy_dir_all(&entry.path(), &dest_path)?;
- } else if file_type.is_symlink() {
- let target = fs::read_link(entry.path()).map_err(|error| {
- format!("could not read symlink {}: {error}", entry.path().display())
- })?;
- symlink(&target, &dest_path)
- .map_err(|error| format!("could not symlink {}: {error}", dest_path.display()))?;
- } else {
- fs::copy(entry.path(), &dest_path)
- .map_err(|error| format!("could not copy {}: {error}", entry.path().display()))?;
- let perms = fs::metadata(entry.path())
- .map_err(|error| format!("could not read {}: {error}", entry.path().display()))?
- .permissions();
- fs::set_permissions(&dest_path, perms).map_err(|error| {
- format!(
- "could not set permissions on {}: {error}",
- dest_path.display()
- )
- })?;
- }
- }
- Ok(())
-}
-
-/// Create a symlink at `link` pointing to `original`.
-#[cfg(unix)]
-fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
- std::os::unix::fs::symlink(original, link)
-}
-
-#[cfg(windows)]
-fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
- if original.is_dir() {
- std::os::windows::fs::symlink_dir(original, link)
- } else {
- std::os::windows::fs::symlink_file(original, link)
- }
-}
-
-/// Run `rustc <toolchain_arg> <args>` and return its stdout, so a missing
-/// toolchain or missing `rustc`/`rustup` shim surfaces as a plain error
-/// rather than a panic.
-fn rustc(toolchain_arg: &str, args: &[&str]) -> Result<String, String> {
- let output = Command::new("rustc")
- .arg(toolchain_arg)
- .args(args)
- .output()
- .map_err(|error| format!("could not run rustc: {error}"))?;
- if !output.status.success() {
- return Err(format!(
- "rustc {toolchain_arg} {} failed: {}",
- args.join(" "),
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- String::from_utf8(output.stdout).map_err(|_error| "rustc output was not valid UTF-8".to_owned())
-}
-
-/// Extract `<name>: <value>` from `rustc -vV`'s line-oriented output.
-fn verbose_field(output: &str, name: &str) -> Option<String> {
- output
- .lines()
- .find_map(|line| line.strip_prefix(&format!("{name}: ")))
- .map(str::to_owned)
-}
crates/git-hydrate/Cargo.toml
@@ -1,27 +1,0 @@
-[package]
-name = "git-hydrate"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-git-backend = { workspace = true }
-git-ents-core = { workspace = true }
-git-member = { workspace = true }
-git-protocol = { workspace = true }
-gix-reachability = { workspace = true }
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-odb-tigris = { workspace = true }
-refstore-postgres = { workspace = true }
-tempfile = { workspace = true }
-
-[dev-dependencies]
-backend-conformance = { workspace = true }
-odb-files = { workspace = true }
-refstore-files = { workspace = true }
-uuid = { workspace = true }
-
-[lints]
-workspace = true
crates/git-hydrate/src/config.rs
@@ -1,86 +1,0 @@
-//! [`HydrateConfig`]: how to reach the durable stores hydration reads from
-//! and writes through. Present (`Some`) enables hydration mode; absent
-//! keeps a deployment on the current direct-disk behavior, per
-//! `docs/scale-out.adoc`'s thesis that cloud deployment is additive
-//! configuration, not a different code path.
-
-use std::path::PathBuf;
-
-use odb_tigris::transport::s3::S3Config;
-
-/// Which [`odb_tigris::transport::BlobTransport`] hydration reads packs
-/// from and writes them to.
-#[derive(Debug, Clone)]
-pub enum BlobStore {
- /// A local directory, standing in for the bucket — used by tests and
- /// small/self-hosted deployments that don't need S3.
- Fs(PathBuf),
- /// A real S3-compatible bucket (Tigris in production).
- S3(S3Config),
-}
-
-/// Everything hydration needs to reach the durable stores: a Postgres
-/// connection string (`refstore-postgres`'s ref store, reflog, pack
-/// registry, and op-replay corpus log) and a blob store (`odb-tigris`'s
-/// packs).
-#[derive(Debug, Clone)]
-pub struct HydrateConfig {
- /// Libpq connection string for the Postgres ref store / pack registry /
- /// corpus log.
- pub postgres_conninfo: String,
- /// Where packs live.
- pub blob: BlobStore,
-}
-
-impl HydrateConfig {
- /// Build a config over a local directory blob store — the common case
- /// for tests and small deployments.
- #[must_use]
- pub fn with_fs_blob(postgres_conninfo: impl Into<String>, root: impl Into<PathBuf>) -> Self {
- Self {
- postgres_conninfo: postgres_conninfo.into(),
- blob: BlobStore::Fs(root.into()),
- }
- }
-
- /// Read a config from the environment, or `None` if hydration is not
- /// configured (the caller should then keep the current direct-disk
- /// behavior). Recognizes:
- ///
- /// - `GIT_ENTS_HYDRATE_POSTGRES_URL` (required to enable hydration).
- /// - `GIT_ENTS_HYDRATE_BLOB_ROOT` — a local directory blob store, or
- /// - `GIT_ENTS_HYDRATE_S3_BUCKET`/`_REGION`/`_ENDPOINT`/
- /// `_ACCESS_KEY_ID`/`_SECRET_ACCESS_KEY`/`_ALLOW_HTTP` — an
- /// S3-compatible bucket. The `Fs` root wins if both are set.
- #[must_use]
- pub fn from_env() -> Option<Self> {
- let postgres_conninfo = env_var("GIT_ENTS_HYDRATE_POSTGRES_URL")?;
- if let Some(root) = env_var("GIT_ENTS_HYDRATE_BLOB_ROOT") {
- return Some(Self {
- postgres_conninfo,
- blob: BlobStore::Fs(PathBuf::from(root)),
- });
- }
- let bucket = env_var("GIT_ENTS_HYDRATE_S3_BUCKET")?;
- let region = env_var("GIT_ENTS_HYDRATE_S3_REGION").unwrap_or_else(|| "auto".to_owned());
- let endpoint = env_var("GIT_ENTS_HYDRATE_S3_ENDPOINT")?;
- let access_key_id = env_var("GIT_ENTS_HYDRATE_S3_ACCESS_KEY_ID")?;
- let secret_access_key = env_var("GIT_ENTS_HYDRATE_S3_SECRET_ACCESS_KEY")?;
- let allow_http = env_var("GIT_ENTS_HYDRATE_S3_ALLOW_HTTP").is_some_and(|v| v == "1");
- Some(Self {
- postgres_conninfo,
- blob: BlobStore::S3(S3Config {
- bucket,
- region,
- endpoint,
- access_key_id,
- secret_access_key,
- allow_http,
- }),
- })
- }
-}
-
-fn env_var(key: &str) -> Option<String> {
- std::env::var(key).ok().filter(|value| !value.is_empty())
-}
crates/git-hydrate/src/hydrate.rs
@@ -1,101 +1,0 @@
-//! The read-path hydration step (`docs/scale-out.adoc`, WS0's read path):
-//! copy a repository's registered packs into a local bare repository's
-//! `objects/pack/`, idempotently.
-
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-
-use git_backend::Result;
-use odb_tigris::registry::PackRegistry;
-use odb_tigris::transport::BlobTransport;
-
-/// Ensure `repo_path` is a bare repository on local (ephemeral) disk
-/// carrying every pack `registry` has registered for `repo_id`, fetched
-/// from `transport`.
-///
-/// Idempotent and cheap to call on every request: a pack already present
-/// locally (named after its [`odb_tigris::registry::PackId`], so presence
-/// is a plain file check) is never re-fetched. Nothing here is
-/// correctness-bearing — ephemeral disk death just means the next call
-/// starts from an empty `objects/pack/` and re-copies everything
-/// (`docs/scale-out.adoc`: "Ephemeral disk death -> re-hydrate. Nothing
-/// correctness-bearing on disk").
-///
-/// # Errors
-///
-/// Returns an error if the bare repository cannot be initialized, the
-/// registry cannot be listed, or a pack/idx cannot be fetched or written.
-pub fn ensure_hydrated<T, R>(
- repo_path: &Path,
- repo_id: &str,
- transport: &T,
- registry: &R,
-) -> Result<()>
-where
- T: BlobTransport,
- R: PackRegistry,
-{
- if !is_bare_repo(repo_path) {
- init_bare_repo(repo_path)?;
- }
- let pack_dir = repo_path.join("objects").join("pack");
- std::fs::create_dir_all(&pack_dir)?;
-
- for record in registry.list(repo_id)? {
- let pack_path = pack_dir.join(format!("pack-{}.pack", record.id.as_str()));
- let idx_path = pack_dir.join(format!("pack-{}.idx", record.id.as_str()));
- if pack_path.is_file() && idx_path.is_file() {
- // Already hydrated from a previous request/instance — the
- // whole point of naming local files after the registry's own
- // pack id.
- continue;
- }
- let pack_bytes = transport.get(&record.pack_key)?;
- let idx_bytes = transport.get(&record.idx_key)?;
- atomic_write(&pack_path, &pack_bytes)?;
- atomic_write(&idx_path, &idx_bytes)?;
- }
- Ok(())
-}
-
-/// Whether `path` is the root of a bare git repository.
-fn is_bare_repo(path: &Path) -> bool {
- path.join("HEAD").is_file() && path.join("objects").is_dir()
-}
-
-/// Create an empty bare repository at `repo_path`, creating parent
-/// directories as needed.
-fn init_bare_repo(repo_path: &Path) -> Result<()> {
- if let Some(parent) = repo_path.parent() {
- std::fs::create_dir_all(parent)?;
- }
- let status = Command::new("git")
- .arg("init")
- .arg("--bare")
- .arg("-q")
- .arg(repo_path)
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()?;
- if !status.success() {
- return Err(git_backend::Error::ObjectStore(
- "git init --bare failed while hydrating a repository".to_owned(),
- ));
- }
- Ok(())
-}
-
-/// Write `bytes` to `path` via a same-directory temp file and rename, so a
-/// reader never observes a partially-written pack or idx.
-fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
- let tmp_path = tmp_path_for(path);
- std::fs::write(&tmp_path, bytes)?;
- std::fs::rename(&tmp_path, path)?;
- Ok(())
-}
-
-fn tmp_path_for(path: &Path) -> PathBuf {
- let mut tmp = path.as_os_str().to_owned();
- tmp.push(".tmp");
- PathBuf::from(tmp)
-}
crates/git-hydrate/src/lib.rs
@@ -1,65 +1,0 @@
-//! WS0 — the interim hydration backend (`docs/scale-out.adoc`, "WS0 —
-//! Interim hydration backend"): stock `git http-backend` over ephemeral
-//! disk, hydrated from the durable stores (`refstore-postgres` for refs,
-//! `odb-tigris` for packs). Not a hack outside the architecture — this
-//! crate *is* the stock-git-wrapped backend the protocol traits permit,
-//! built first, exactly as the doc's decision record on invariant
-//! stratification describes.
-//!
-//! # Read path
-//!
-//! [`hydrate::ensure_hydrated`] copies a repository's registered packs
-//! (`.pack` + `.idx`, from [`odb_tigris::registry::PackRegistry`]) into
-//! `objects/pack/` of a local bare repository, skipping any pack already
-//! present by its own content-addressed filename — idempotent, and cheap on
-//! every call after the first: ephemeral disk death means nothing more than
-//! re-copying everything again next time (`docs/scale-out.adoc`: "nothing
-//! correctness-bearing on ephemeral disk"). [`packed_refs::regenerate`]
-//! rewrites `packed-refs` from one `RefStore::iter_prefix("refs/")` scan,
-//! atomically, to bound advertisement staleness — call it on every
-//! `info/refs` request, not just the first.
-//!
-//! # Write path
-//!
-//! [`pre_receive::run`] is the `pre-receive` hook body for a repository
-//! configured with a [`config::HydrateConfig`]: it authenticates and applies
-//! the push through [`git_protocol::native::NativeBackend::receive`]
-//! (`IngestPack`) against a [`resolver::PostgresResolver`] — Postgres as the
-//! ref store, Tigris (or a local directory in tests) as the object store —
-//! exactly the "IngestPack via receive-pack against a scratch repo with
-//! Postgres as the commit point" shape `docs/scale-out.adoc`'s "Protocol
-//! traits" section names as a conforming implementation. `receive-pack`'s
-//! own tmp objdir plays no special role here (unlike a hand-rolled
-//! quarantine): staging, the atomic ref transaction, and promotion are all
-//! `NativeBackend::receive`'s existing, already-tested ordering, so causal
-//! collection safety holds by construction, not by convention. Local disk
-//! is a demoted cache: git's own post-hook ref update reconciles it to
-//! match Postgres automatically, since our applied edits are the exact ones
-//! `receive-pack` was asked to make. The one ref this doesn't reconcile
-//! locally — `refs/meta/ops/log`, added to the same atomic transaction
-//! internally — self-heals on the next `info/refs` (packed-refs
-//! regeneration reads every ref back from Postgres, this one included).
-//!
-//! Every accepted push through this path also logs a
-//! [`git_protocol::CorpusEntry`] (see [`refstore_postgres::PostgresRefStore::log_corpus_entry`]):
-//! the seed corpus `backend_conformance::replay_corpus` replays against the
-//! local files backends (WS2).
-//!
-//! # Known limits (short-term, accepted)
-//!
-//! - Whole-pack hydration makes first-touch read latency scale with repo
-//! size; ranged reads (WS5) are the fix, not this crate's job.
-//! - Concurrent pushes to one repo from multiple serve machines are safe
-//! under Postgres's compare-and-swap (no split-brain ref state is ever
-//! possible), but a machine whose local disk cache is stale relative to
-//! another machine's last-accepted push will advertise a stale `old` and
-//! see spurious rejections until its next `info/refs` re-hydration. Pin
-//! writes for one repository to one machine, or accept client retries.
-
-pub mod config;
-pub mod hydrate;
-pub mod packed_refs;
-pub mod pre_receive;
-pub mod resolver;
-
-pub use config::HydrateConfig;
crates/git-hydrate/src/packed_refs.rs
@@ -1,44 +1,0 @@
-//! The read-path's other half (`docs/scale-out.adoc`, WS0's read path):
-//! regenerate `packed-refs` from the ref store on every `info/refs`
-//! request, bounding advertisement staleness to one request's worth.
-
-use std::path::Path;
-
-use git_backend::{RefName, RefStore, Result};
-
-/// Rewrite `repo_path`'s `packed-refs` from one
-/// [`RefStore::iter_prefix`]`("refs/")` scan over `refs`, atomically (a
-/// temp file, then a rename) so a concurrent `git` reader never observes a
-/// half-written file.
-///
-/// No peeled (`^{}`) entries are emitted for annotated tags — this rewrite
-/// intentionally does not claim the `fully-peeled` trait git's
-/// `packed-refs` format supports, so a reader that needs a tag's peeled
-/// target still resolves it correctly by opening the tag object itself,
-/// just without the fast path a fully-peeled file would offer. Correctness
-/// over an optimization this backend does not need yet.
-///
-/// # Errors
-///
-/// Returns an error if `refs` cannot be scanned or the file cannot be
-/// written.
-pub fn regenerate(repo_path: &Path, refs: &dyn RefStore) -> Result<()> {
- let mut entries: Vec<(String, String)> = refs
- .iter_prefix(&RefName::new("refs/"))?
- .map(|entry| entry.map(|(name, oid)| (name.as_str().to_owned(), oid.to_hex().to_string())))
- .collect::<Result<_>>()?;
- entries.sort();
-
- let mut body = String::from("# pack-refs with: sorted\n");
- for (name, oid) in entries {
- body.push_str(&oid);
- body.push(' ');
- body.push_str(&name);
- body.push('\n');
- }
-
- let tmp_path = repo_path.join("packed-refs.tmp");
- std::fs::write(&tmp_path, body)?;
- std::fs::rename(&tmp_path, repo_path.join("packed-refs"))?;
- Ok(())
-}
crates/git-hydrate/src/pre_receive.rs
@@ -1,249 +1,0 @@
-//! The `pre-receive` hook body for a hydration-configured repository
-//! (`docs/scale-out.adoc`, WS0's write path): apply the push through
-//! [`git_protocol::native::NativeBackend::receive`] against
-//! [`crate::resolver::PostgresResolver`], then log the accepted push's
-//! [`git_protocol::CorpusEntry`] for later replay (WS2's seed corpus).
-
-use std::io::{Cursor, Read as _};
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-use std::sync::Arc;
-
-use git_backend::{Expected, PackStream, RefEdit, RefName};
-use git_protocol::attestation::{OpSigner, SshOpSigner};
-use git_protocol::native::NativeBackend;
-use git_protocol::{
- CorpusEntry, IngestPack as _, PushCertificate, PushOutcome, PushRequest, RepoId,
-};
-use gix_hash::ObjectId;
-
-use crate::config::HydrateConfig;
-use crate::resolver::PostgresResolver;
-
-/// One ref update as git hands it to `pre-receive` on stdin.
-struct RefUpdate {
- name: RefName,
- old: Option<ObjectId>,
- new: Option<ObjectId>,
-}
-
-impl RefUpdate {
- fn to_ref_edit(&self) -> RefEdit {
- RefEdit {
- name: self.name.clone(),
- expected: match self.old {
- Some(oid) => Expected::MustExistAndMatch(oid),
- None => Expected::MustNotExist,
- },
- new: self.new,
- }
- }
-}
-
-/// Run the hook: read the push git is about to apply, commit it through
-/// the durable stores, and log its corpus entry.
-///
-/// `op_signing_key` signs the accepted push's server op record; `None`
-/// rejects every push closed (mirrors `git_ents_server::native_git`'s own
-/// rule: no signing key configured, no accepted push — reads are
-/// unaffected). `config` names the Postgres/blob-store pair this
-/// repository hydrates from and writes through.
-///
-/// # Errors
-///
-/// Returns `Err(reason)` — the caller prints `reason` to stderr and exits
-/// non-zero, rejecting the whole push — if the ref updates or push
-/// certificate cannot be read, the incoming pack cannot be built, or the
-/// push itself is rejected (failed attestation, failed connectivity, or a
-/// failed compare-and-swap against Postgres).
-pub fn run(config: &HydrateConfig, op_signing_key: Option<&Path>) -> Result<(), String> {
- let repo_path =
- std::env::current_dir().map_err(|error| format!("cannot resolve repository: {error}"))?;
- let repo_id = repo_id_for(&repo_path);
-
- let updates = read_ref_updates()?;
- if updates.is_empty() {
- return Ok(());
- }
-
- let roots: Vec<ObjectId> = updates.iter().filter_map(|update| update.new).collect();
- let pack_bytes = build_pack(&repo_path, &roots)?;
-
- let cert_text = read_push_cert(&repo_path)?;
- let cert_bytes = cert_text
- .as_deref()
- .map(str::as_bytes)
- .unwrap_or_default()
- .to_vec();
- let cert_oid =
- gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, &cert_bytes)
- .map_err(|error| format!("could not hash push certificate: {error}"))?;
-
- let signer: Arc<dyn OpSigner> = match op_signing_key {
- Some(key) => Arc::new(SshOpSigner::new(key.to_path_buf())),
- // No signing key: op records fail to sign, so every otherwise
- // acceptable push is rejected — fail-closed, since an accepted
- // push without its op record breaks the "universal server op
- // record" rule.
- None => Arc::new(SshOpSigner::new(PathBuf::from("/dev/null"))),
- };
- let resolver = PostgresResolver::new(config.clone(), repo_path.clone());
- let backend = NativeBackend::new(resolver, signer);
-
- let ref_edits: Vec<RefEdit> = updates.iter().map(RefUpdate::to_ref_edit).collect();
- let push = PushRequest {
- repo: RepoId::new(repo_id.clone()),
- ref_edits,
- pack: PackStream::new(Cursor::new(pack_bytes.clone())),
- push_cert: cert_text.clone().map(PushCertificate::new),
- };
-
- match backend.receive(push).map_err(|error| error.to_string())? {
- PushOutcome::Accepted { applied, .. } => {
- let entry =
- CorpusEntry::new(cert_text.is_some().then_some(cert_oid), applied, pack_bytes);
- log_corpus_entry(config, &repo_id, &entry);
- Ok(())
- }
- PushOutcome::Rejected { reason } => Err(reason),
- }
-}
-
-/// This repository's id, relative to `$GIT_PROJECT_ROOT` when set (the
-/// same env var `git-ents-server`'s CGI gateway hands every backend
-/// invocation, inherited down through `receive-pack` to this hook) —
-/// otherwise the repository's own directory name, so the hook still runs
-/// (against a single-repo id) outside that server.
-fn repo_id_for(repo_path: &Path) -> String {
- if let Ok(root) = std::env::var("GIT_PROJECT_ROOT")
- && let Ok(relative) = repo_path.strip_prefix(root)
- && !relative.as_os_str().is_empty()
- {
- return relative.to_string_lossy().replace('\\', "/");
- }
- repo_path
- .file_name()
- .map(|name| name.to_string_lossy().into_owned())
- .unwrap_or_else(|| repo_path.to_string_lossy().into_owned())
-}
-
-/// Read the ref updates git hands `pre-receive` on stdin: `<old> <new>
-/// <refname>` per line.
-fn read_ref_updates() -> Result<Vec<RefUpdate>, String> {
- let mut input = String::new();
- std::io::stdin()
- .read_to_string(&mut input)
- .map_err(|error| format!("could not read ref updates: {error}"))?;
-
- let null = ObjectId::null(gix_hash::Kind::Sha1);
- let mut updates = Vec::new();
- for line in input.lines() {
- let mut parts = line.split_whitespace();
- let (Some(old_hex), Some(new_hex), Some(name)) = (parts.next(), parts.next(), parts.next())
- else {
- continue;
- };
- let old = ObjectId::from_hex(old_hex.as_bytes())
- .map_err(|error| format!("bad old oid {old_hex:?}: {error}"))?;
- let new = ObjectId::from_hex(new_hex.as_bytes())
- .map_err(|error| format!("bad new oid {new_hex:?}: {error}"))?;
- updates.push(RefUpdate {
- name: RefName::new(name),
- old: (old != null).then_some(old),
- new: (new != null).then_some(new),
- });
- }
- Ok(updates)
-}
-
-/// The push certificate git verified the nonce of, read from the blob
-/// `$GIT_PUSH_CERT` names — `None` when the push carried no certificate at
-/// all (only acceptable during the bootstrap window, which
-/// `NativeBackend::receive`'s attestation check enforces).
-///
-/// # Errors
-///
-/// Returns `Err` if a certificate was sent but its anti-replay nonce did
-/// not validate, or its blob cannot be read.
-fn read_push_cert(repo: &Path) -> Result<Option<String>, String> {
- let Some(oid) = std::env::var("GIT_PUSH_CERT")
- .ok()
- .filter(|value| !value.is_empty())
- else {
- return Ok(None);
- };
- if std::env::var("GIT_PUSH_CERT_NONCE_STATUS").as_deref() != Ok("OK") {
- return Err("push certificate nonce was missing or stale".to_owned());
- }
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["cat-file", "blob", &oid])
- .output()
- .map_err(|error| format!("could not read push certificate: {error}"))?;
- if !output.status.success() {
- return Err("could not read the push certificate from the object store".to_owned());
- }
- String::from_utf8(output.stdout)
- .map(Some)
- .map_err(|_invalid| "push certificate is not valid UTF-8".to_owned())
-}
-
-/// Build the pack introducing every object reachable from `roots` that
-/// `repo` (plus its inherited quarantine — `$GIT_OBJECT_DIRECTORY`/
-/// `$GIT_ALTERNATE_OBJECT_DIRECTORIES`, set by `receive-pack` for this
-/// very hook) doesn't already have, by shelling out to `git rev-list`/`git
-/// pack-objects` exactly as a real client push transmits one. An empty
-/// `roots` (a batch of pure ref deletions) still needs a valid, empty pack.
-fn build_pack(repo: &Path, roots: &[ObjectId]) -> Result<Vec<u8>, String> {
- if roots.is_empty() {
- return git_protocol::pack::build_pack(&[]).map_err(|error| error.to_string());
- }
- let mut rev_list = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["rev-list", "--objects"])
- .args(roots.iter().map(|oid| oid.to_hex().to_string()))
- .args(["--not", "--all"])
- .stdout(Stdio::piped())
- .spawn()
- .map_err(|error| format!("could not spawn git rev-list: {error}"))?;
- let rev_list_stdout = rev_list
- .stdout
- .take()
- .ok_or_else(|| "git rev-list produced no stdout".to_owned())?;
- let pack_objects = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["pack-objects", "--stdout", "-q"])
- .stdin(rev_list_stdout)
- .stdout(Stdio::piped())
- .spawn()
- .map_err(|error| format!("could not spawn git pack-objects: {error}"))?;
- let output = pack_objects
- .wait_with_output()
- .map_err(|error| format!("git pack-objects failed: {error}"))?;
- let rev_list_status = rev_list
- .wait()
- .map_err(|error| format!("git rev-list failed: {error}"))?;
- if !rev_list_status.success() {
- return Err("git rev-list failed while building the push's pack".to_owned());
- }
- if !output.status.success() {
- return Err("git pack-objects failed while building the push's pack".to_owned());
- }
- Ok(output.stdout)
-}
-
-/// Best-effort: the push already committed to Postgres by the time this
-/// runs, so a corpus-logging failure must not undo (or even report as
-/// failing) an otherwise-accepted push — the same "a failure here cannot
-/// undo the push" stance `git_effect::engine::post_receive` takes.
-fn log_corpus_entry(config: &HydrateConfig, repo_id: &str, entry: &CorpusEntry) {
- let Ok(store) =
- refstore_postgres::PostgresRefStore::connect(&config.postgres_conninfo, repo_id.to_owned())
- else {
- return;
- };
- let _ignored = store.log_corpus_entry(entry);
-}
crates/git-hydrate/src/resolver.rs
@@ -1,89 +1,0 @@
-//! [`PostgresResolver`]: a [`git_protocol::native::BackendResolver`] over
-//! the durable stores — Postgres for refs (and, doubling as the pack
-//! registry, for `odb-tigris`'s bookkeeping), the configured blob store for
-//! packs. Feeding this resolver to
-//! [`git_protocol::native::NativeBackend`] is what makes
-//! [`crate::pre_receive::run`] the "IngestPack via receive-pack against a
-//! scratch repo with Postgres as the commit point" backend
-//! `docs/scale-out.adoc`'s "Protocol traits" section names.
-
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use git_backend::ObjectStore;
-use git_protocol::native::{BackendResolver, RepoBackends};
-use git_protocol::types::RepoId;
-use odb_tigris::OdbTigris;
-use odb_tigris::transport::fs::FsTransport;
-use odb_tigris::transport::s3::S3Transport;
-use refstore_postgres::PostgresRefStore;
-
-use crate::config::{BlobStore, HydrateConfig};
-
-/// Resolves a [`RepoId`] to `refstore-postgres`/`odb-tigris` backends, and
-/// to the repository's currently enrolled members/config — read from the
-/// local hydrated disk cache at `repo_path`, exactly as
-/// `git_ents_server::native_git::DiskResolver` reads them for the WS3
-/// native path, and as `git-signed-push`'s own `pre-receive` verifier
-/// always has: a fresh disk read per call, which is close enough to
-/// Postgres truth by the time a push reaches this resolver (`packed-refs`
-/// was just regenerated from Postgres on the preceding `info/refs`).
-pub struct PostgresResolver {
- config: HydrateConfig,
- repo_path: PathBuf,
-}
-
-impl PostgresResolver {
- /// Resolve repositories through `config`'s durable stores, reading
- /// members/config from the local hydrated cache at `repo_path`.
- #[must_use]
- pub fn new(config: HydrateConfig, repo_path: impl Into<PathBuf>) -> Self {
- Self {
- config,
- repo_path: repo_path.into(),
- }
- }
-}
-
-impl BackendResolver for PostgresResolver {
- fn resolve(&self, repo: &RepoId) -> git_protocol::Result<RepoBackends> {
- let refs = PostgresRefStore::connect(&self.config.postgres_conninfo, repo.as_str())?;
- let registry = PostgresRefStore::connect(&self.config.postgres_conninfo, repo.as_str())?;
- let objects: Arc<dyn ObjectStore> = match &self.config.blob {
- BlobStore::Fs(root) => {
- let transport = FsTransport::open(root)?;
- Arc::new(OdbTigris::new(
- transport,
- registry,
- repo.as_str().to_owned(),
- ))
- }
- BlobStore::S3(s3_config) => {
- let transport = S3Transport::connect(s3_config)?;
- Arc::new(OdbTigris::new(
- transport,
- registry,
- repo.as_str().to_owned(),
- ))
- }
- };
-
- let members = git_member::members::load_all(&self.repo_path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let revoked = git_member::revocations::fingerprints(&self.repo_path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
- let config = git_ents_core::config::load(&self.repo_path)
- .map_err(|error| git_protocol::Error::UnknownRepo(error.to_string()))?;
-
- Ok(RepoBackends {
- refs: Arc::new(refs),
- objects,
- authorized_members: git_member::members::without_revoked(members, &revoked),
- config,
- // No reachability artifacts wired for this resolver yet (same
- // gap `DiskResolver` documents): negotiation/ingest degrade to
- // the plain walk, never a wrong answer.
- reachability: gix_reachability::ArtifactBundle::empty(),
- })
- }
-}
crates/git-maintenance/Cargo.toml
@@ -1,30 +1,0 @@
-[package]
-name = "git-maintenance"
-version = "0.0.0"
-edition.workspace = true
-publish.workspace = true
-license.workspace = true
-
-[dependencies]
-backend-conformance = { workspace = true }
-effect-dispatcher = { workspace = true }
-git-backend = { workspace = true }
-git-protocol = { workspace = true }
-gix-reachability = { workspace = true }
-gix-hash = { workspace = true }
-gix-object = { workspace = true }
-gix-pack = { workspace = true }
-odb-files = { workspace = true }
-odb-tigris = { workspace = true }
-refstore-files = { workspace = true }
-refstore-postgres = { workspace = true }
-uuid = { workspace = true }
-
-[dev-dependencies]
-git-store = { workspace = true, features = ["test-support"] }
-tempfile = { workspace = true }
-tokio = { workspace = true }
-tokio-postgres = { workspace = true }
-
-[lints]
-workspace = true
crates/git-maintenance/src/cache.rs
@@ -1,303 +1,0 @@
-//! Cache-namespace maintenance (`docs/scale-out.adoc`, rule 4 and WS9):
-//! TTL eviction of cache refs, and the consolidation effect — the only
-//! multi-ref cache writer, and load-bearing rather than hygiene: without
-//! it the per-key writer discipline (one ref per key, no CAS contention
-//! between concurrent workers) would grow the ref namespace without bound.
-//!
-//! # Eviction
-//!
-//! Rule 4: "Eviction = ref deletion + registry delete." [`evict_expired`]
-//! is the ref-deletion half; the registry delete follows structurally from
-//! the pack-lifetime rule (rule 5): cache objects live in their own packs,
-//! so once their refs are gone the next [`crate::gc::collect`] finds those
-//! packs fully unreachable and deletes them whole — registry delete plus
-//! blob delete, never repack surgery.
-//!
-//! # Consolidation
-//!
-//! [`consolidate`] compacts `refs/cache/<namespace>/<key>` per-key refs
-//! into one tree under [`git_backend::cache_ns::consolidated_ref`] and
-//! deletes the per-key refs, all in **one atomic multi-ref transaction**
-//! (the `RefStore` contract makes that contractual, not best-effort). The
-//! new tree objects are staged and promoted *before* the transaction —
-//! promoted-but-unreferenced objects are invisible to reachability until
-//! the ref commit (rule 2), and this ordering means a failure at any point
-//! leaves every key resolvable: before the transaction the per-key refs
-//! still stand; after it the consolidated tree answers. There is no state
-//! in between (see [`git_backend::cache_ns::resolve`], the shared read
-//! path).
-
-use std::collections::BTreeMap;
-use std::time::Duration;
-
-use git_backend::cache_ns;
-use git_backend::{Expected, ObjectStore, PackStream, RefEdit, RefName, RefStore, TxOutcome};
-use gix_hash::ObjectId;
-use gix_object::WriteTo as _;
-
-use crate::{Error, Result};
-
-/// Delete every cache ref (both [`cache_ns::CACHE_PREFIXES`] namespaces)
-/// whose latest reflog entry is older than `ttl` as of `now_secs` (seconds
-/// since the epoch — injected rather than read from a clock so callers and
-/// tests share one notion of "now"). Returns the refs evicted.
-///
-/// Each eviction is its own single-ref compare-and-swap transaction: a ref
-/// a concurrent writer moves between read and delete is simply skipped
-/// (the write refreshed it). A ref with no reflog entry is kept — with no
-/// timestamp there is no expiry to assert, and keeping a cache entry is
-/// always safe (rule 4: reconstructible, evictable *later*).
-///
-/// Plain `RefStore` transactions, not attested pushes: cache namespaces
-/// are exempt from provenance (rule 4).
-///
-/// # Errors
-///
-/// Returns an error if the ref store fails; never because a CAS lost a
-/// race.
-pub fn evict_expired(refs: &dyn RefStore, ttl: Duration, now_secs: u64) -> Result<Vec<RefName>> {
- let mut evicted = Vec::new();
- for prefix in cache_ns::CACHE_PREFIXES {
- let entries: Vec<(RefName, ObjectId)> = refs
- .iter_prefix(&RefName::new(prefix))?
- .collect::<git_backend::Result<_>>()?;
- for (name, oid) in entries {
- let Some(written_secs) = latest_write_secs(refs, &name)? else {
- continue;
- };
- if now_secs.saturating_sub(written_secs) <= ttl.as_secs() {
- continue;
- }
- let outcome = refs.transaction(&[RefEdit {
- name: name.clone(),
- expected: Expected::MustExistAndMatch(oid),
- new: None,
- }])?;
- if matches!(outcome, TxOutcome::Applied) {
- evicted.push(name);
- }
- }
- }
- Ok(evicted)
-}
-
-/// The epoch seconds of `name`'s most recent reflog entry, or `None` if it
-/// has no reflog.
-fn latest_write_secs(refs: &dyn RefStore, name: &RefName) -> Result<Option<u64>> {
- match refs.log(name)?.next() {
- Some(entry) => Ok(Some(entry?.seconds)),
- None => Ok(None),
- }
-}
-
-/// A prepared consolidation: the tree already staged and promoted, and the
-/// one multi-ref transaction that publishes it. Split from
-/// [`consolidate`] so the atomicity boundary is testable: a failure after
-/// [`prepare_consolidation`] but before [`commit_consolidation`] must
-/// leave every key resolvable through its per-key ref.
-#[derive(Debug)]
-pub struct ConsolidationPlan {
- /// The consolidated tree's root, already promoted (visible to reads,
- /// unreachable until the transaction commits — rule 2).
- pub tree: ObjectId,
- /// The atomic edit batch: publish the consolidated ref, delete every
- /// per-key ref, all-or-nothing.
- pub edits: Vec<RefEdit>,
- /// How many keys this plan consolidates.
- pub keys: usize,
-}
-
-/// Build `namespace`'s consolidation: read every per-key ref, merge with
-/// the existing consolidated tree (per-key wins — it is always current,
-/// see [`cache_ns::resolve`]), write the merged tree's objects through the
-/// staged-pack path (no `write_loose` exists — rule 2's staging applies to
-/// maintenance too), promote them, and return the transaction that
-/// publishes the result. `None` when there are no per-key refs to compact.
-///
-/// # Errors
-///
-/// Returns an error if any store operation fails, or if two keys collide
-/// as blob-vs-directory in the tree (e.g. keys `a` and `a/b`) — a
-/// namespace whose writer permits that cannot be consolidated into a tree.
-pub fn prepare_consolidation(
- namespace: &str,
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
-) -> Result<Option<ConsolidationPlan>> {
- let prefix = cache_ns::per_key_prefix(namespace);
- let per_key: Vec<(RefName, ObjectId)> = refs
- .iter_prefix(&prefix)?
- .collect::<git_backend::Result<_>>()?;
- if per_key.is_empty() {
- return Ok(None);
- }
-
- let consolidated = cache_ns::consolidated_ref(namespace);
- let existing = refs.get(&consolidated)?;
-
- let mut root = match existing {
- Some(tree) => read_tree_node(objects, tree)?,
- None => Node::Dir(BTreeMap::new()),
- };
- for (name, oid) in &per_key {
- let key = name
- .as_str()
- .strip_prefix(prefix.as_str())
- .ok_or_else(|| Error::RefStore(format!("{name} is not under {prefix}")))?;
- insert_key(&mut root, key, *oid)?;
- }
-
- let mut new_objects = Vec::new();
- let tree = write_node(&root, &mut new_objects)?;
- let pack = git_protocol::pack::build_pack(&new_objects)
- .map_err(|error| Error::ObjectStore(error.to_string()))?;
- let quarantine = objects.stage_pack(PackStream::new(std::io::Cursor::new(pack)))?;
- objects.promote(quarantine)?;
-
- let mut edits = vec![RefEdit {
- name: consolidated,
- expected: match existing {
- Some(old) => Expected::MustExistAndMatch(old),
- None => Expected::MustNotExist,
- },
- new: Some(tree),
- }];
- let keys = per_key.len();
- edits.extend(per_key.into_iter().map(|(name, oid)| RefEdit {
- name,
- expected: Expected::MustExistAndMatch(oid),
- new: None,
- }));
-
- Ok(Some(ConsolidationPlan { tree, edits, keys }))
-}
-
-/// Apply a [`ConsolidationPlan`]'s transaction. `Ok(true)` when it
-/// applied; `Ok(false)` when a concurrent writer moved any touched ref and
-/// the whole batch was rejected — nothing changed (all-or-nothing), the
-/// next maintenance run re-prepares against the new state.
-///
-/// # Errors
-///
-/// Returns an error only if the ref store itself fails.
-pub fn commit_consolidation(refs: &dyn RefStore, plan: &ConsolidationPlan) -> Result<bool> {
- Ok(matches!(refs.transaction(&plan.edits)?, TxOutcome::Applied))
-}
-
-/// The consolidation effect (`docs/scale-out.adoc`, rule 4): compact
-/// `namespace`'s per-key cache refs into its consolidated tree ref in one
-/// atomic multi-ref transaction. Returns how many keys were consolidated —
-/// `0` when there was nothing to do or a concurrent writer won the race.
-///
-/// # Errors
-///
-/// See [`prepare_consolidation`] and [`commit_consolidation`].
-pub fn consolidate(
- namespace: &str,
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
-) -> Result<usize> {
- let Some(plan) = prepare_consolidation(namespace, refs, objects)? else {
- return Ok(0);
- };
- if commit_consolidation(refs, &plan)? {
- Ok(plan.keys)
- } else {
- Ok(0)
- }
-}
-
-/// An in-memory consolidated tree under construction: cache blobs at the
-/// leaves, directories per key path segment.
-enum Node {
- Leaf(ObjectId),
- Dir(BTreeMap<String, Node>),
-}
-
-/// Insert `key` (slash-separated path) pointing at `oid` into `root`,
-/// failing on a blob-vs-directory collision rather than silently dropping
-/// either side.
-fn insert_key(root: &mut Node, key: &str, oid: ObjectId) -> Result<()> {
- let mut node = root;
- let mut segments = key.split('/').peekable();
- while let Some(segment) = segments.next() {
- let Node::Dir(children) = node else {
- return Err(Error::ObjectStore(format!(
- "cache key {key} collides with another key at segment {segment}"
- )));
- };
- if segments.peek().is_none() {
- children.insert(segment.to_owned(), Node::Leaf(oid));
- return Ok(());
- }
- node = children
- .entry(segment.to_owned())
- .or_insert_with(|| Node::Dir(BTreeMap::new()));
- }
- Ok(())
-}
-
-/// Read an existing consolidated tree back into a [`Node`]: tree entries
-/// recurse, everything else is a leaf.
-fn read_tree_node(objects: &dyn ObjectStore, tree: ObjectId) -> Result<Node> {
- let object = objects.read(tree)?;
- if object.kind != gix_object::Kind::Tree {
- return Err(Error::ObjectStore(format!(
- "consolidated ref points at a {:?}, not a tree",
- object.kind
- )));
- }
- let parsed = gix_object::TreeRef::from_bytes(&object.data, gix_hash::Kind::Sha1)
- .map_err(|error| Error::ObjectStore(format!("malformed consolidated tree: {error}")))?;
- let mut children = BTreeMap::new();
- for entry in parsed.entries {
- let name = std::str::from_utf8(entry.filename)
- .map_err(|_error| {
- Error::ObjectStore("consolidated tree entry name is not UTF-8".to_owned())
- })?
- .to_owned();
- let child = if entry.mode.is_tree() {
- read_tree_node(objects, entry.oid.to_owned())?
- } else {
- Node::Leaf(entry.oid.to_owned())
- };
- children.insert(name, child);
- }
- Ok(Node::Dir(children))
-}
-
-/// Write `node` (and every subtree) as tree objects, appending each new
-/// tree to `out` for packing, returning `node`'s id. Leaves are recorded
-/// as plain blobs — cache values are content blobs, their bytes already in
-/// the store.
-fn write_node(node: &Node, out: &mut Vec<git_protocol::pack::PackObject>) -> Result<ObjectId> {
- match node {
- Node::Leaf(oid) => Ok(*oid),
- Node::Dir(children) => {
- let mut entries = Vec::with_capacity(children.len());
- for (name, child) in children {
- let oid = write_node(child, out)?;
- entries.push(gix_object::tree::Entry {
- mode: match child {
- Node::Leaf(_oid) => gix_object::tree::EntryKind::Blob.into(),
- Node::Dir(_children) => gix_object::tree::EntryKind::Tree.into(),
- },
- filename: name.as_str().into(),
- oid,
- });
- }
- entries.sort();
- let tree = gix_object::Tree { entries };
- let mut data = Vec::new();
- tree.write_to(&mut data)?;
- let oid = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Tree, &data)
- .map_err(|error| Error::ObjectStore(error.to_string()))?;
- out.push(git_protocol::pack::PackObject {
- id: oid,
- kind: gix_object::Kind::Tree,
- data,
- });
- Ok(oid)
- }
- }
-}
crates/git-maintenance/src/collector.rs
@@ -1,110 +1,0 @@
-//! Real [`backend_conformance::Collector`]s — the seam WS2 left open, now
-//! closed: the causal-collection-safety property (`docs/scale-out.adoc`,
-//! correctness rule 1) runs against collection passes that actually
-//! collect, instead of [`backend_conformance::NoopCollector`].
-//!
-//! Both collectors panic if a collection pass errors: they exist to drive
-//! a conformance property, and a collector that swallowed its own failure
-//! would let the property pass vacuously ("no fake assertions").
-
-use std::time::Duration;
-
-use backend_conformance::Collector;
-use git_backend::RefStore;
-use odb_tigris::OdbTigris;
-use odb_tigris::registry::PackRegistry;
-use odb_tigris::transport::BlobTransport;
-
-/// A [`Collector`] over one [`OdbTigris`] store: one collection pass =
-/// expire staging sessions past their grace window (the grace-based cruft
-/// arm of rule 1, a no-op for a store without one), then a full
-/// mark-and-sweep ([`crate::gc::collect`]).
-pub struct TigrisCollector<'a, T, R> {
- repo_id: &'a str,
- refs: &'a dyn RefStore,
- store: &'a OdbTigris<T, R>,
- transport: &'a dyn BlobTransport,
- registry: &'a dyn PackRegistry,
-}
-
-impl<'a, T, R> TigrisCollector<'a, T, R>
-where
- T: BlobTransport,
- R: PackRegistry,
-{
- /// A collector over `store`, marking from `refs` and sweeping via
- /// `registry`/`transport` — the same transport and registry `store`
- /// itself was built over.
- #[must_use]
- pub fn new(
- repo_id: &'a str,
- refs: &'a dyn RefStore,
- store: &'a OdbTigris<T, R>,
- transport: &'a dyn BlobTransport,
- registry: &'a dyn PackRegistry,
- ) -> Self {
- Self {
- repo_id,
- refs,
- store,
- transport,
- registry,
- }
- }
-}
-
-impl<T, R> Collector for TigrisCollector<'_, T, R>
-where
- T: BlobTransport,
- R: PackRegistry,
-{
- #[expect(
- clippy::expect_used,
- reason = "conformance driver: a failed collection pass must fail the \
- property loudly, never let it pass vacuously"
- )]
- fn collect(&self) {
- self.store
- .expire_stale_quarantines()
- .expect("expire stale quarantines");
- crate::gc::collect(
- self.repo_id,
- self.refs,
- self.store,
- self.transport,
- self.registry,
- )
- .expect("mark-and-sweep collection pass");
- }
-
- fn staging_grace(&self) -> Option<Duration> {
- self.store.staging_grace()
- }
-}
-
-/// A [`Collector`] over a local bare repository (`refstore-files` +
-/// `odb-files`): one collection pass = [`crate::gc::collect_files`]. No
-/// grace window ([`Collector::staging_grace`] stays `None`) — the local
-/// backend bounds staging by promotion alone, never by a clock.
-pub struct FilesCollector {
- repo: std::path::PathBuf,
-}
-
-impl FilesCollector {
- /// A collector over the bare repository at `repo`.
- #[must_use]
- pub fn new(repo: impl Into<std::path::PathBuf>) -> Self {
- Self { repo: repo.into() }
- }
-}
-
-impl Collector for FilesCollector {
- #[expect(
- clippy::expect_used,
- reason = "conformance driver: a failed collection pass must fail the \
- property loudly, never let it pass vacuously"
- )]
- fn collect(&self) {
- crate::gc::collect_files(&self.repo).expect("files mark-and-sweep collection pass");
- }
-}
crates/git-maintenance/src/gc.rs
@@ -1,313 +1,0 @@
-//! Mark-and-sweep GC (`docs/scale-out.adoc`, WS9: "Mark from RefStore via
-//! reachability artifacts; sweep via pack registry").
-//!
-//! # Mark
-//!
-//! [`collect`] marks with [`gix_reachability::gc_mark`] — every object
-//! reachable from every current ref tip, accelerated by whatever
-//! reachability artifacts the repo has (absence degrades speed, never
-//! answers). A second, durable-tips-only walk splits the marked set into
-//! lifetime classes ([`odb_tigris::pack_writer::LifetimeClass`]) so the
-//! sweep's repack path can honor the pack-lifetime rule (rule 5).
-//!
-//! # Sweep — and why quarantine is structurally safe
-//!
-//! The sweep enumerates [`odb_tigris::registry::PackRegistry::list`] and
-//! nothing else. Quarantined (staged) packs are *not in the registry* —
-//! [`odb_tigris::OdbTigris::promote`] is what records a pack, and it is
-//! only called after the ref transaction the pack was staged for commits —
-//! and [`odb_tigris::transport::BlobTransport`] exposes no listing call at
-//! all, so there is no API through which this module *could* scan
-//! quarantine (correctness rules 1 and 2: "GC never scans quarantine").
-//! That safety is structural, not a filter this code must remember to
-//! apply.
-//!
-//! Grace-based staging (rule 1's time-bounded arm) lives on the store
-//! itself: [`odb_tigris::OdbTigris::with_staging_grace`] bounds staging
-//! sessions (a session past its window aborts at `promote` rather than
-//! becoming collectible mid-flight), and
-//! [`odb_tigris::OdbTigris::expire_stale_quarantines`] is the cruft pass a
-//! grace-based collector runs — see [`crate::collector::TigrisCollector`].
-//!
-//! # Sweep outcomes per pack
-//!
-//! - every object unreachable → **delete**: registry delete (the commit
-//! point), then best-effort blob deletes.
-//! - every object reachable → keep.
-//! - mixed, with at least one durable reachable object → **rewrite**:
-//! reachable objects are repacked through the WS5 pack writer
-//! ([`odb_tigris::pack_writer::partition_and_pack`], which partitions by
-//! lifetime class so cache and durable objects never share the new
-//! pack), the new pack(s) are recorded, and only then is the old pack
-//! deleted — no window where a live object is unregistered.
-//! - mixed, all reachable objects cache-lifetime → left whole: cache packs
-//! die by registry delete when their refs are evicted, never repack
-//! surgery (rule 5).
-
-use std::collections::BTreeSet;
-
-use git_backend::cache_ns;
-use git_backend::{ObjectStore, RefName, RefStore};
-use gix_hash::ObjectId;
-use gix_reachability::walk::StoreSource;
-use odb_tigris::pack_writer::{ClassifiedObject, LifetimeClass, index_pack, partition_and_pack};
-use odb_tigris::registry::{PackId, PackRecord, PackRegistry};
-use odb_tigris::transport::BlobTransport;
-
-use crate::{Error, Result};
-
-/// What one [`collect`] pass did.
-#[derive(Debug, Default, Clone, PartialEq, Eq)]
-pub struct GcOutcome {
- /// Packs whose objects were all unreachable, deleted whole.
- pub deleted_packs: usize,
- /// Mixed packs rewritten to contain only their reachable objects.
- pub rewritten_packs: usize,
- /// The size of the marked (reachable) set.
- pub live_objects: usize,
-}
-
-/// One full mark-and-sweep pass for `repo_id` (see the module docs for the
-/// mark/sweep design and the structural quarantine-safety argument).
-///
-/// # Errors
-///
-/// Returns an error if the mark walk fails (a ref tip whose history is
-/// incomplete is corruption, never grounds to collect), or if a registry
-/// or transport operation the sweep depends on fails.
-pub fn collect(
- repo_id: &str,
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
- transport: &dyn BlobTransport,
- registry: &dyn PackRegistry,
-) -> Result<GcOutcome> {
- let artifacts = gix_reachability::store::load_bundle(transport, registry, repo_id)
- .map_err(|error| Error::ObjectStore(error.to_string()))?;
- let marked = gix_reachability::gc_mark(refs, objects, &artifacts)
- .map_err(|error| Error::ObjectStore(error.to_string()))?;
- let durable = durable_reachable(refs, objects, &artifacts)?;
-
- let mut outcome = GcOutcome {
- live_objects: marked.len(),
- ..GcOutcome::default()
- };
-
- for record in registry.list(repo_id)? {
- let ids = pack_object_ids(transport, &record)?;
- let live: Vec<ObjectId> = ids
- .iter()
- .filter(|id| marked.contains(*id))
- .copied()
- .collect();
-
- if live.is_empty() {
- // Registry delete first — it is the commit point; blob deletes
- // after it are best-effort cleanup (a leaked key wastes space,
- // never correctness), mirroring `OdbTigris::promote`.
- registry.delete(repo_id, &record.id)?;
- let _ignored = transport.delete(&record.pack_key);
- let _ignored = transport.delete(&record.idx_key);
- outcome.deleted_packs = outcome.deleted_packs.saturating_add(1);
- continue;
- }
- if live.len() == ids.len() {
- continue;
- }
-
- // Mixed pack. A pack whose reachable objects are all
- // cache-lifetime is a cache pack: never repack surgery (rule 5) —
- // it dies whole once its cache refs are evicted.
- if live.iter().all(|id| !durable.contains(id)) {
- continue;
- }
-
- rewrite_pack(
- repo_id, objects, transport, registry, &record, &live, &durable,
- )?;
- outcome.rewritten_packs = outcome.rewritten_packs.saturating_add(1);
- }
- Ok(outcome)
-}
-
-/// The objects reachable from durable (non-cache) ref tips alone — the
-/// lifetime-class oracle for repack partitioning: marked objects in this
-/// set are [`LifetimeClass::Durable`], marked objects outside it are
-/// reachable only through cache refs and so [`LifetimeClass::Cache`].
-fn durable_reachable(
- refs: &dyn RefStore,
- objects: &dyn ObjectStore,
- artifacts: &gix_reachability::ArtifactBundle,
-) -> Result<BTreeSet<ObjectId>> {
- let tips = refs
- .iter_prefix(&RefName::new("refs/"))?
- .filter(|entry| match entry {
- Ok((name, _oid)) => !cache_ns::is_cache_ref(name),
- Err(_error) => true,
- })
- .map(|entry| entry.map(|(_name, oid)| oid))
- .collect::<git_backend::Result<Vec<ObjectId>>>()?;
- let source = StoreSource::new(objects);
- gix_reachability::accelerated_reachable(tips, &source, |_id| false, false, artifacts)
- .map_err(|error| Error::ObjectStore(error.to_string()))
-}
-
-/// Every object id in `record`'s pack, read from its `.idx` — never from a
-/// bucket listing (the transport has none to offer).
-fn pack_object_ids(transport: &dyn BlobTransport, record: &PackRecord) -> Result<Vec<ObjectId>> {
- let bytes = transport.get(&record.idx_key)?;
- let idx = gix_pack::index::File::from_data(
- bytes,
- std::path::PathBuf::from(&record.idx_key),
- gix_hash::Kind::Sha1,
- )
- .map_err(|error| Error::ObjectStore(format!("parsing index {}: {error}", record.idx_key)))?;
- Ok(idx.iter().map(|entry| entry.oid).collect())
-}
-
-/// Repack `live` (the reachable objects of a mixed pack) into fresh
-/// pack(s) through the WS5 pack writer — partitioned by lifetime class, so
-/// the pack-lifetime rule survives the rewrite — record them, and only
-/// then delete the old pack.
-fn rewrite_pack(
- repo_id: &str,
- objects: &dyn ObjectStore,
- transport: &dyn BlobTransport,
- registry: &dyn PackRegistry,
- record: &PackRecord,
- live: &[ObjectId],
- durable: &BTreeSet<ObjectId>,
-) -> Result<()> {
- let classified: Vec<ClassifiedObject> = live
- .iter()
- .map(|id| {
- let object = objects.read(*id)?;
- Ok(ClassifiedObject {
- id: *id,
- kind: object.kind,
- data: object.data,
- lifetime: if durable.contains(id) {
- LifetimeClass::Durable
- } else {
- LifetimeClass::Cache
- },
- })
- })
- .collect::<Result<_>>()?;
-
- let packs = partition_and_pack(classified)?;
- for pack_bytes in [packs.durable, packs.cache].into_iter().flatten() {
- record_new_pack(repo_id, transport, registry, pack_bytes)?;
- }
-
- registry.delete(repo_id, &record.id)?;
- let _ignored = transport.delete(&record.pack_key);
- let _ignored = transport.delete(&record.idx_key);
- Ok(())
-}
-
-/// Index freshly written pack bytes, upload them at new live keys, and
-/// record them — the same key layout `OdbTigris` promotes into.
-fn record_new_pack(
- repo_id: &str,
- transport: &dyn BlobTransport,
- registry: &dyn PackRegistry,
- pack_bytes: Vec<u8>,
-) -> Result<()> {
- let (pack, idx) = index_pack(pack_bytes)?;
- let object_count = count_pack_objects(&idx);
- let id = uuid::Uuid::new_v4().to_string();
- let pack_key = format!("{repo_id}/live/{id}.pack");
- let idx_key = format!("{repo_id}/live/{id}.idx");
- transport.put(&pack_key, pack)?;
- transport.put(&idx_key, idx)?;
- registry.record(PackRecord {
- id: PackId::new(id),
- repo_id: repo_id.to_owned(),
- pack_key,
- idx_key,
- object_count,
- })
-}
-
-/// The object count out of freshly written `.idx` bytes, or `None` if they
-/// fail to parse — the count is informational only ([`PackRecord`]'s field
-/// docs), so an unparsable count is not worth failing a rewrite over.
-fn count_pack_objects(idx_bytes: &[u8]) -> Option<u64> {
- gix_pack::index::File::from_data(
- idx_bytes.to_vec(),
- std::path::PathBuf::from("rewrite.idx"),
- gix_hash::Kind::Sha1,
- )
- .ok()
- .map(|idx| u64::from(idx.num_objects()))
-}
-
-/// What one [`collect_files`] pass did.
-#[derive(Debug, Default, Clone, PartialEq, Eq)]
-pub struct FilesGcOutcome {
- /// Packs under `objects/pack/` whose objects were all unreachable,
- /// deleted whole.
- pub deleted_packs: usize,
- /// The size of the marked (reachable) set.
- pub live_objects: usize,
-}
-
-/// Mark-and-sweep for the local files backend (`refstore-files` +
-/// `odb-files`): mark from ref tips, then delete every pack under
-/// `objects/pack/` whose objects are all unreachable. Whole-pack reaping
-/// only — the local backend has no pack registry to rewrite through, and
-/// mixed packs are simply kept (correct, just less compact; the
-/// registry-backed sweep is where rewriting lives).
-///
-/// Structurally quarantine-safe for the same reason `odb-files` itself is:
-/// this sweep scans `objects/pack/` and nothing else, and staged packs
-/// live under `objects/quarantine/<id>/` until promoted.
-///
-/// # Errors
-///
-/// Returns an error if the repository cannot be opened, the mark walk
-/// fails, or a doomed pack cannot be deleted.
-pub fn collect_files(repo: &std::path::Path) -> Result<FilesGcOutcome> {
- let (marked, doomed) = {
- let refs = refstore_files::FilesRefStore::open(repo)?;
- let objects = odb_files::OdbFiles::open(repo)?;
- let marked =
- gix_reachability::gc_mark(&refs, &objects, &gix_reachability::ArtifactBundle::empty())
- .map_err(|error| Error::ObjectStore(error.to_string()))?;
-
- let pack_dir = repo.join("objects").join("pack");
- let mut doomed = Vec::new();
- if pack_dir.is_dir() {
- for entry in std::fs::read_dir(&pack_dir)? {
- let path = entry?.path();
- if path.extension().is_some_and(|ext| ext == "idx") {
- let idx = gix_pack::index::File::at(&path, gix_hash::Kind::Sha1).map_err(
- |error| {
- Error::ObjectStore(format!("parsing index {}: {error}", path.display()))
- },
- )?;
- if idx.iter().all(|entry| !marked.contains(&entry.oid)) {
- doomed.push(path);
- }
- }
- }
- }
- (marked, doomed)
- // `objects` (and its pack mmaps) drop here, before any deletion.
- };
-
- let mut outcome = FilesGcOutcome {
- live_objects: marked.len(),
- ..FilesGcOutcome::default()
- };
- for idx_path in doomed {
- std::fs::remove_file(&idx_path)?;
- let pack_path = idx_path.with_extension("pack");
- if pack_path.exists() {
- std::fs::remove_file(&pack_path)?;
- }
- outcome.deleted_packs = outcome.deleted_packs.saturating_add(1);
- }
- Ok(outcome)
-}
crates/git-maintenance/src/lib.rs
@@ -1,38 +1,0 @@
-//! WS9: GC, compaction, and maintenance (`docs/scale-out.adoc`, "WS9 —
-//! GC, compaction, maintenance").
-//!
-//! > Per-repo background effects serialized by advisory lock. Mark from
-//! > RefStore via reachability artifacts; sweep via pack registry; cruft
-//! > semantics where grace-based. Cache-ref TTL deletion; the
-//! > consolidation effect from rule 4 lives here and is load-bearing.
-//! > Reachability-artifact regeneration scheduled here.
-//!
-//! The pieces, one module each:
-//!
-//! - [`gc`] — mark ([`gix_reachability::gc_mark`], artifacts as
-//! accelerator) and sweep (over the pack registry, never a bucket
-//! listing and *structurally* never quarantine — see the module doc).
-//! - [`cache`] — TTL eviction of cache refs and the consolidation effect,
-//! the only multi-ref cache writer (`docs/scale-out.adoc`, rule 4).
-//! - [`lock`] — the per-repo advisory lock every maintenance run holds for
-//! its whole duration, so concurrent dispatchers can't double-run a
-//! repo: a Postgres advisory lock in cloud deployments, a file lock
-//! locally.
-//! - [`schedule`] — the maintenance [`git_backend::EffectDef`]s and the
-//! [`schedule::Scheduler`] a server calls post-ingest to enqueue them on
-//! ref-update volume thresholds (including reachability regeneration via
-//! [`gix_reachability::maintenance::should_regenerate`], the trigger WS6
-//! left for this crate to schedule).
-//! - [`collector`] — real [`backend_conformance::Collector`]s over the
-//! files and Tigris backends, closing the seam WS2 left open: the
-//! causal-collection-safety property now runs against a collection pass
-//! that actually collects, including the staging-timeout boundary
-//! (`docs/scale-out.adoc`, correctness rule 1).
-
-pub mod cache;
-pub mod collector;
-pub mod gc;
-pub mod lock;
-pub mod schedule;
-
-pub use git_backend::{Error, Result};
crates/git-maintenance/src/lock.rs
@@ -1,140 +1,0 @@
-//! Per-repo maintenance serialization (`docs/scale-out.adoc`, WS9:
-//! "Per-repo background effects serialized by advisory lock").
-//!
-//! [`run_exclusive`] wraps a *whole* maintenance run in one
-//! [`MaintenanceLock`] acquisition, so two dispatchers (or a dispatcher
-//! and an operator's manual run) can never double-run one repository: the
-//! second acquirer skips — maintenance is periodic and idempotent, so
-//! "skip and let the next trigger retry" beats blocking a dispatcher
-//! thread on another machine's run.
-//!
-//! Two implementations, one per deployment shape:
-//! - [`FileMaintenanceLock`] — an OS advisory file lock (`flock`-style,
-//! via `std::fs::File::try_lock`) beside the local bare repository.
-//! - [`PgMaintenanceLock`] — a Postgres session advisory lock keyed by
-//! repo id, for Postgres-backed deployments where the contending
-//! dispatchers are on different machines (see
-//! `refstore_postgres::PostgresRefStore::try_maintenance_lock`).
-
-use std::path::{Path, PathBuf};
-
-use crate::Result;
-
-/// Holds a per-repo maintenance lock; released on drop. The release action
-/// is captured as a closure so file and Postgres guards share one type.
-pub struct MaintenanceGuard<'a> {
- release: Option<Box<dyn FnOnce() + 'a>>,
-}
-
-impl Drop for MaintenanceGuard<'_> {
- fn drop(&mut self) {
- if let Some(release) = self.release.take() {
- release();
- }
- }
-}
-
-/// A per-repo advisory lock a maintenance run holds for its whole
-/// duration.
-pub trait MaintenanceLock {
- /// Try to take the lock: `Some(guard)` when this caller now holds it,
- /// `None` when another maintenance run does (the caller should skip).
- ///
- /// # Errors
- ///
- /// Returns an error if the locking mechanism itself fails — never for
- /// mere contention, which is the `None` case.
- fn try_acquire(&self) -> Result<Option<MaintenanceGuard<'_>>>;
-}
-
-/// Run `work` under `lock`, holding it for the whole run. `Ok(None)` means
-/// another run holds the lock and this one was skipped.
-///
-/// # Errors
-///
-/// Returns an error if acquiring fails or `work` fails.
-pub fn run_exclusive<T>(
- lock: &dyn MaintenanceLock,
- work: impl FnOnce() -> Result<T>,
-) -> Result<Option<T>> {
- let Some(guard) = lock.try_acquire()? else {
- return Ok(None);
- };
- let outcome = work()?;
- drop(guard);
- Ok(Some(outcome))
-}
-
-/// [`MaintenanceLock`] over an OS advisory file lock — the local
-/// deployment's serializer, correct across processes on one machine.
-pub struct FileMaintenanceLock {
- path: PathBuf,
-}
-
-impl FileMaintenanceLock {
- /// A lock at `path` (created if absent; its content is never read).
- #[must_use]
- pub fn new(path: impl Into<PathBuf>) -> Self {
- Self { path: path.into() }
- }
-
- /// The conventional lock for the bare repository at `repo`:
- /// `<repo>/maintenance.lock`.
- #[must_use]
- pub fn for_repo(repo: &Path) -> Self {
- Self::new(repo.join("maintenance.lock"))
- }
-}
-
-impl MaintenanceLock for FileMaintenanceLock {
- fn try_acquire(&self) -> Result<Option<MaintenanceGuard<'_>>> {
- let file = std::fs::OpenOptions::new()
- .create(true)
- .truncate(false)
- .write(true)
- .open(&self.path)?;
- match file.try_lock() {
- // Dropping the file both unlocks and closes it.
- Ok(()) => Ok(Some(MaintenanceGuard {
- release: Some(Box::new(move || drop(file))),
- })),
- Err(std::fs::TryLockError::WouldBlock) => Ok(None),
- Err(std::fs::TryLockError::Error(error)) => Err(error.into()),
- }
- }
-}
-
-/// [`MaintenanceLock`] over a Postgres session advisory lock keyed by the
-/// store's repo id — the cloud deployment's serializer, correct across
-/// machines because the lock lives in the one Postgres primary every
-/// dispatcher already talks to.
-pub struct PgMaintenanceLock<'a> {
- store: &'a refstore_postgres::PostgresRefStore,
-}
-
-impl<'a> PgMaintenanceLock<'a> {
- /// Lock through `store`'s connection (session advisory locks are held
- /// by the session — this store's connection — and released on
- /// [`MaintenanceGuard`] drop or session death, so a crashed
- /// maintenance run never wedges the repo).
- #[must_use]
- pub fn new(store: &'a refstore_postgres::PostgresRefStore) -> Self {
- Self { store }
- }
-}
-
-impl MaintenanceLock for PgMaintenanceLock<'_> {
- fn try_acquire(&self) -> Result<Option<MaintenanceGuard<'_>>> {
- if !self.store.try_maintenance_lock()? {
- return Ok(None);
- }
- let store = self.store;
- Ok(Some(MaintenanceGuard {
- release: Some(Box::new(move || {
- // Session death releases the lock anyway; a failed explicit
- // unlock is not worth panicking a Drop over.
- let _ignored = store.unlock_maintenance();
- })),
- }))
- }
-}
crates/git-maintenance/src/schedule.rs
@@ -1,231 +1,0 @@
-//! Scheduling maintenance (`docs/scale-out.adoc`, WS9 and "Reachability":
-//! "Regeneration is scheduled with repack (WS9) and triggered by
-//! ref-update volume thresholds").
-//!
-//! The maintenance effects — GC, cache TTL, consolidation, and
-//! reachability regeneration ([`gix_reachability::maintenance`], whose
-//! scheduling WS6 explicitly deferred here) — are defined as
-//! [`EffectDef`]s and enqueued by [`schedule_maintenance`] whenever a
-//! repo's accumulated ref-update volume crosses its threshold. The
-//! [`Scheduler`] is the piece a server holds: it does the per-repo
-//! accumulation so the ingest path only has to report "this push applied
-//! N ref edits" (see `git-ents-server`'s native receive-pack endpoint, the
-//! wired call site).
-//!
-//! Like `reachability-maintenance`, these effects run as in-process
-//! maintenance code, so their [`EffectDef::command`] is `None`; the queue
-//! rows are the schedule, and the runner executes the bodies
-//! ([`crate::gc::collect`], [`crate::cache::evict_expired`],
-//! [`crate::cache::consolidate`],
-//! [`gix_reachability::maintenance::regenerate`]) under the per-repo
-//! advisory lock ([`crate::lock`]).
-
-use std::collections::{BTreeMap, HashMap};
-use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
-
-use git_backend::{EffectDef, MaterializedInputs};
-
-use crate::Result;
-
-/// The GC effect's name (mark-and-sweep, [`crate::gc::collect`]).
-pub const GC_EFFECT: &str = "maintenance-gc";
-
-/// The cache TTL eviction effect's name ([`crate::cache::evict_expired`]).
-pub const CACHE_TTL_EFFECT: &str = "maintenance-cache-ttl";
-
-/// The cache consolidation effect's name ([`crate::cache::consolidate`]).
-pub const CONSOLIDATION_EFFECT: &str = "maintenance-cache-consolidation";
-
-/// The static [`EffectDef`] for one in-process maintenance effect —
-/// `command`/`image` `None`, mirroring
-/// [`gix_reachability::maintenance::definition`].
-fn definition(name: &str) -> EffectDef {
- EffectDef {
- name: name.to_owned(),
- command: None,
- image: None,
- }
-}
-
-/// The ref-update volume thresholds that trigger maintenance. Two knobs
-/// because reachability regeneration has its own trigger predicate
-/// ([`gix_reachability::maintenance::should_regenerate`]) and may
-/// reasonably fire less often than repack.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Thresholds {
- /// Ref updates before a maintenance run (GC, TTL, consolidation) is
- /// enqueued.
- pub maintenance: u64,
- /// Ref updates before reachability regeneration rides along
- /// ([`gix_reachability::maintenance::should_regenerate`]).
- pub reachability: u64,
-}
-
-impl Default for Thresholds {
- fn default() -> Self {
- Self {
- maintenance: 64,
- reachability: 64,
- }
- }
-}
-
-/// Per-repo maintenance-relevant activity since the last scheduled run.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Stats {
- /// Applied ref edits since maintenance was last enqueued for the repo.
- pub ref_updates_since_last: u64,
-}
-
-/// Where scheduled maintenance effects land — the effect queue in a
-/// Postgres deployment ([`PostgresQueueSink`]), anything else a test or a
-/// future local runner supplies.
-pub trait MaintenanceSink: Send + Sync {
- /// Enqueue `effects` for `repo_id`, in order.
- ///
- /// # Errors
- ///
- /// Returns an error if the underlying queue cannot be written.
- fn enqueue(&self, repo_id: &str, effects: &[EffectDef]) -> Result<()>;
-}
-
-/// Enqueue `repo_id`'s maintenance effects into `sink` if `stats` crosses
-/// `thresholds.maintenance` — GC, cache TTL, consolidation, plus
-/// reachability regeneration when
-/// [`gix_reachability::maintenance::should_regenerate`] says the volume
-/// also warrants that. Returns what was enqueued (empty below threshold).
-///
-/// # Errors
-///
-/// Returns an error if the sink fails; nothing is retried here — the
-/// caller's accumulated count survives (see [`Scheduler`]) so the next
-/// update re-triggers.
-pub fn schedule_maintenance(
- repo_id: &str,
- stats: &Stats,
- thresholds: &Thresholds,
- sink: &dyn MaintenanceSink,
-) -> Result<Vec<EffectDef>> {
- if stats.ref_updates_since_last < thresholds.maintenance {
- return Ok(Vec::new());
- }
- let mut effects = vec![
- definition(GC_EFFECT),
- definition(CACHE_TTL_EFFECT),
- definition(CONSOLIDATION_EFFECT),
- ];
- if gix_reachability::maintenance::should_regenerate(
- stats.ref_updates_since_last,
- thresholds.reachability,
- ) {
- effects.push(gix_reachability::maintenance::definition());
- }
- sink.enqueue(repo_id, &effects)?;
- Ok(effects)
-}
-
-/// The per-repo accumulator a server holds: ingest reports applied ref
-/// edits through [`Scheduler::note_ref_updates`], and once a repo's count
-/// crosses the threshold its maintenance effects are enqueued and the
-/// count reset. On a sink failure the count is restored, so a transient
-/// queue outage delays maintenance rather than losing the trigger.
-pub struct Scheduler {
- thresholds: Thresholds,
- sink: Arc<dyn MaintenanceSink>,
- counts: Mutex<HashMap<String, u64>>,
-}
-
-impl Scheduler {
- /// A scheduler enqueuing into `sink` at `thresholds`.
- #[must_use]
- pub fn new(thresholds: Thresholds, sink: Arc<dyn MaintenanceSink>) -> Self {
- Self {
- thresholds,
- sink,
- counts: Mutex::new(HashMap::new()),
- }
- }
-
- /// Record that a push applied `updates` ref edits to `repo_id`,
- /// enqueuing the repo's maintenance effects if that crosses the
- /// threshold. Returns what was enqueued (usually nothing).
- ///
- /// # Errors
- ///
- /// Returns an error if the sink fails — the accumulated count is
- /// restored first, so the trigger is delayed, not lost.
- pub fn note_ref_updates(&self, repo_id: &str, updates: u64) -> Result<Vec<EffectDef>> {
- let due = {
- let mut counts = lock(&self.counts);
- let count = counts.entry(repo_id.to_owned()).or_insert(0);
- *count = count.saturating_add(updates);
- if *count >= self.thresholds.maintenance {
- let accumulated = *count;
- *count = 0;
- Some(accumulated)
- } else {
- None
- }
- };
- let Some(accumulated) = due else {
- return Ok(Vec::new());
- };
- let stats = Stats {
- ref_updates_since_last: accumulated,
- };
- match schedule_maintenance(repo_id, &stats, &self.thresholds, &*self.sink) {
- Ok(effects) => Ok(effects),
- Err(error) => {
- let mut counts = lock(&self.counts);
- let count = counts.entry(repo_id.to_owned()).or_insert(0);
- *count = count.saturating_add(accumulated);
- Err(error)
- }
- }
- }
-}
-
-fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
- mutex.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-/// [`MaintenanceSink`] over the Postgres effect queue: each effect is
-/// encoded with [`effect_dispatcher::job::encode`] — the payload shape the
-/// WS7 dispatcher drains — with a null tree, since maintenance effects run
-/// against repository state, not a materialized input tree.
-///
-/// Connects per enqueue call: enqueues happen once per threshold crossing,
-/// not per push, so a short-lived connection is the simple correct choice
-/// over holding one open on the ingest path.
-pub struct PostgresQueueSink {
- conninfo: String,
-}
-
-impl PostgresQueueSink {
- /// A sink enqueuing into the queue at `conninfo` (a libpq connection
- /// string).
- #[must_use]
- pub fn new(conninfo: impl Into<String>) -> Self {
- Self {
- conninfo: conninfo.into(),
- }
- }
-}
-
-impl MaintenanceSink for PostgresQueueSink {
- fn enqueue(&self, repo_id: &str, effects: &[EffectDef]) -> Result<()> {
- let store = refstore_postgres::PostgresRefStore::connect(&self.conninfo, repo_id)?;
- for effect in effects {
- let payload = effect_dispatcher::job::encode(&effect_dispatcher::job::Job {
- effect: effect.clone(),
- inputs: MaterializedInputs {
- tree: gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
- toolchain_paths: BTreeMap::new(),
- cache: None,
- },
- });
- store.enqueue_effect(&payload)?;
- }
- Ok(())
- }
-}
Diff truncated (over 1 MiB).