git-ents.gitmain
⌘K
foforge
commit 2f64f47
roots: add git-ents, the local and single-node hosted composition roots

git-ents is the CLI-complete milestone: two composition roots (LocalRoot, HostedRoot) wire RefStore/odb/EventSink/Executor per roots.local, above a cli.rs/exe.rs split (figue derive definitions vs. dispatch) and a commands/ module per porcelain family (setup, members, account, effect, toolchain, comment, inbox, redact). mutate.rs is the one shared primitive every write command uses to build, sign, and propose a typed-tree mutation through ents_receive::receive; sign.rs is the real SSH signer counterpart to ents-testutil’s fixture Keypair, since the redone architecture builds and signs commits in-process rather than shelling to git commit -S the way pre-redo’s CLI did.

HostedRoot doubles as the single-node hosted root the development plan describes: stock git’s own receive-pack stays the transport, with hook.rs’s pre-receive/post-receive plumbing running the identical ents_gate::verify every other call site uses and reconciling effect obligations from repository state alone (receive.reconstructible) - deliberately not ents_receive::receive’s own RefStore::transaction path, since that would race git’s native ref update in this deployment shape (see hook.rs’s own doc). QuarantineObjects chains a pre-receive quarantine directory in front of the real odb in-process, rather than writing a physical info/alternates file: that first attempt was briefly tried and reverted after it surfaced a real bug - git’s quarantine finalization moves the quarantine directory’s contents, including anything a hook wrote into it, onto the real object directory, permanently self-referencing objects/info/alternates and failing every later push.

setup.rs also closes roots.worktree-update: git ents setup sets receive.denyCurrentBranch=updateInstead via a git config subprocess, since gix’s own config-snapshot API has no file-persistence path at all (confirmed empirically - SnapshotMut::commit only updates the in-memory resolved view a Repository handle holds).

New dependencies: ssh-key + rand_core (real, non-fixture SSH signing and key generation) and uuid (comment ids) - all added to git-ents only. ents-anchor and ents-effect move from members-only to workspace.dependencies, since this is their first consumer.

Assisted-by: Claude:claude-sonnet-4-6

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -26,6 +26,16 @@ "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" @@ -97,6 +107,12 @@ "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" @@ -115,12 +131,24 @@ 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 = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cipher" version = "0.4.4" @@ -152,6 +180,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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 = "cpufeatures" version = "0.2.17" @@ -185,6 +228,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[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-bigint" version = "0.5.5" @@ -275,7 +345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -288,6 +358,28 @@ "zeroize", ] +[[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" @@ -300,6 +392,15 @@ "subtle", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -380,7 +481,7 @@ "proptest", "rstest", "tempfile", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -400,7 +501,7 @@ "gix-ref-store", "rstest", "tempfile", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -417,7 +518,7 @@ "gix-ref-store", "rstest", "ssh-key", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -431,7 +532,7 @@ "gix-object", "proptest", "rstest", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -447,7 +548,7 @@ "gix-ref-store", "proptest", "rstest", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -465,7 +566,7 @@ "gix-object", "gix-ref-store", "rstest", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -483,7 +584,7 @@ "gix-ref-store", "proptest", "rstest", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -514,7 +615,7 @@ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -536,9 +637,43 @@ 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]] @@ -550,7 +685,20 @@ "gix-hash", "gix-object", "gix-odb", - "thiserror", + "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]] @@ -607,6 +755,18 @@ "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" @@ -619,6 +779,17 @@ "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 = "faster-hex" version = "0.10.0" @@ -651,6 +822,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[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 = "filetime" version = "0.2.29" @@ -760,6 +966,35 @@ "r-efi 6.0.0", ] +[[package]] +name = "git-ents" +version = "0.0.0" +dependencies = [ + "ents-anchor", + "ents-effect", + "ents-gate", + "ents-model", + "ents-query", + "ents-receive", + "ents-sync", + "ents-testutil", + "facet", + "facet-git-tree", + "facet-pretty", + "figue", + "gix", + "gix-hash", + "gix-object", + "gix-odb", + "gix-ref-store", + "rand_core 0.6.4", + "rstest", + "ssh-key", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "gix" version = "0.84.0" @@ -817,9 +1052,9 @@ "nonempty", "parking_lot", "regex", - "signal-hook", + "signal-hook 0.4.4", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -859,7 +1094,7 @@ "gix-trace", "kstring", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", "unicode-bom", ] @@ -889,7 +1124,7 @@ "gix-traverse", "gix-worktree", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -942,7 +1177,7 @@ "gix-ref", "gix-sec", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", "unicode-bom", ] @@ -956,7 +1191,7 @@ "bstr", "gix-path", "libc", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -974,7 +1209,7 @@ "gix-sec", "gix-trace", "gix-url", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1010,7 +1245,7 @@ "gix-trace", "gix-traverse", "gix-worktree", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1030,7 +1265,7 @@ "gix-trace", "gix-utils", "gix-worktree", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1045,7 +1280,7 @@ "gix-path", "gix-ref", "gix-sec", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1074,7 +1309,7 @@ "once_cell", "parking_lot", "prodash", - "thiserror", + "thiserror 2.0.18", "walkdir", "zlib-rs", ] @@ -1097,7 +1332,7 @@ "gix-trace", "gix-utils", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1111,7 +1346,7 @@ "gix-features", "gix-path", "gix-utils", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1135,7 +1370,7 @@ "faster-hex", "gix-features", "sha1-checked", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1197,7 +1432,7 @@ "memmap2", "rustix", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1208,7 +1443,7 @@ dependencies = [ "gix-tempfile", "gix-utils", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1253,7 +1488,7 @@ "gix-validate", "itoa", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1274,7 +1509,7 @@ "memmap2", "parking_lot", "tempfile", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1293,7 +1528,7 @@ "gix-path", "memmap2", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", "uluru", ] @@ -1306,7 +1541,7 @@ "bstr", "faster-hex", "gix-trace", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1318,7 +1553,7 @@ "bstr", "gix-trace", "gix-validate", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1333,7 +1568,7 @@ "gix-config-value", "gix-glob", "gix-path", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1346,7 +1581,7 @@ "gix-config-value", "parking_lot", "rustix", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1365,7 +1600,7 @@ "gix-utils", "maybe-async", "nonempty", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1396,7 +1631,7 @@ "gix-utils", "gix-validate", "memmap2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1408,7 +1643,7 @@ "gix-lock", "rstest", "tempfile", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1424,7 +1659,7 @@ "gix-revision", "gix-validate", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1459,7 +1694,7 @@ "gix-hashtable", "gix-object", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1471,7 +1706,7 @@ "bitflags 2.13.0", "gix-path", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1484,7 +1719,7 @@ "gix-hash", "gix-lock", "nonempty", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1507,7 +1742,7 @@ "gix-pathspec", "gix-worktree", "portable-atomic", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1522,7 +1757,7 @@ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1535,7 +1770,7 @@ "gix-fs", "libc", "parking_lot", - "signal-hook", + "signal-hook 0.4.4", "signal-hook-registry", "tempfile", ] @@ -1559,7 +1794,7 @@ "gix-quote", "gix-sec", "gix-url", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1576,7 +1811,7 @@ "gix-object", "gix-revwalk", "smallvec 1.15.2", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1588,7 +1823,7 @@ "bstr", "gix-path", "percent-encoding", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1644,7 +1879,7 @@ "gix-path", "gix-worktree", "io-close", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1729,6 +1964,18 @@ "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.12.1" @@ -1791,6 +2038,23 @@ "winapi", ] +[[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 = "itoa" version = "1.0.18" @@ -1839,6 +2103,17 @@ "jiff-tzdb", ] +[[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" @@ -1875,6 +2150,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[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" @@ -1916,12 +2197,36 @@ "libc", ] +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "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.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nonempty" version = "0.12.0" @@ -1979,6 +2284,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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 = "p256" version = "0.13.2" @@ -2386,7 +2701,7 @@ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2509,6 +2824,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[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" @@ -2519,6 +2844,17 @@ "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" @@ -2627,6 +2963,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[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" @@ -2639,6 +2984,25 @@ 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" @@ -2660,7 +3024,28 @@ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "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]] @@ -2669,7 +3054,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "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]] @@ -2728,6 +3124,37 @@ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 = "typenum" version = "1.20.1" @@ -2770,6 +3197,18 @@ "tinyvec", ] +[[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" @@ -2781,12 +3220,32 @@ "rustc-hash", ] +[[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 = [ + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2821,6 +3280,57 @@ "wit-bindgen", ] +[[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-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 = "weavy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30261027ea4225cf7c8287aa70bd103c1e78508606c77caf24bb468326ce7b5f" + [[package]] name = "winapi" version = "0.3.9" @@ -2843,7 +3353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2858,6 +3368,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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" @@ -2867,6 +3386,70 @@ "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" @@ -2882,6 +3465,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "xterm-query" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e468ba3129b3d5acd2655410cf12adccd818746e2b8d7fd4b2d22a6e3515ec98" +dependencies = [ + "nix", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.8.54"
Cargo.toml @@ -9,6 +9,7 @@ "crates/ents-receive", "crates/ents-sync", "crates/ents-testutil", + "crates/git-ents", "crates/gix-ref-store", ] @@ -22,6 +23,8 @@ missing_docs = "warn" [workspace.dependencies] +ents-anchor = { path = "crates/ents-anchor" } +ents-effect = { path = "crates/ents-effect" } ents-gate = { path = "crates/ents-gate" } ents-model = { path = "crates/ents-model" } ents-query = { path = "crates/ents-query" }
crates/git-ents/Cargo.toml @@ -1,0 +1,40 @@ +[package] +name = "git-ents" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[[bin]] +name = "git-ents" +path = "src/main.rs" + +[dependencies] +ents-anchor = { workspace = true } +ents-effect = { workspace = true, features = ["docker", "sprite"] } +ents-gate = { workspace = true } +ents-model = { workspace = true } +ents-query = { workspace = true } +ents-receive = { workspace = true } +ents-sync = { workspace = true } +facet = { workspace = true } +facet-git-tree = { workspace = true } +facet-pretty = { workspace = true } +figue = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +gix-odb = { workspace = true } +rand_core = { version = "0.6", features = ["getrandom"] } +ssh-key = { version = "0.6", features = ["ed25519"] } +tempfile = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +ents-testutil = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true
crates/git-ents/src/cli.rs @@ -1,0 +1,345 @@ +//! `git ents`'s argument grammar — `figue` derive definitions only. +//! +//! Per this project's engineering conventions, this module carries no +//! logic: every doc comment here becomes `--help` text, and +//! [`crate::exe`] is the only place a [`Top`] variant is interpreted. + +use std::path::PathBuf; + +use facet::Facet; +use figue::{self as args, FigueBuiltins}; + +/// Local root wiring, subcommand surface, and the single-node hosted +/// root's git-hook plumbing (`docs/development-plan.adoc`, phase 6). +#[derive(Facet)] +pub struct Cli { + /// The subcommand to run. + #[facet(args::subcommand)] + pub command: Top, + /// `--help`/`--version`/`--completions` wiring `figue` provides for + /// every CLI built on it. + #[facet(flatten)] + pub builtins: FigueBuiltins, +} + +/// Every top-level `git ents` subcommand. +// @relation(roots.local, roots.worktree-update, scope=file) +#[derive(Facet)] +#[repr(u8)] +pub enum Top { + /// Configure this repository for signed local writes: resolve or + /// generate a signing key, record it as `user.signingkey` with + /// `gpg.format=ssh`, and set `receive.denyCurrentBranch=updateInstead` + /// so the integration-test harness can push into this repository's + /// checked-out branch (`roots.worktree-update`). + Setup { + /// Key to sign with; defaults to `user.signingkey`, else a new + /// `~/.ssh/id_ed25519` is generated. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Manage the repository members at `refs/meta/member/<username>`. + Members { + /// The member action to run. + #[facet(args::subcommand)] + action: MembersAction, + }, + /// Manage this repository's account identity at `refs/meta/account`. + Account { + /// The account action to run. + #[facet(args::subcommand)] + action: AccountAction, + }, + /// Manage the configured effects at `refs/meta/effects/<name>` and run + /// them locally. + Effect { + /// The effect action to run. + #[facet(args::subcommand)] + action: EffectAction, + }, + /// Manage the toolchains stored as git trees at + /// `refs/meta/toolchains/<name>`. + Toolchain { + /// The toolchain action to run. + #[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 { + /// The comment action to run. + #[facet(args::subcommand)] + action: CommentAction, + }, + /// Work with entities awaiting adoption at + /// `refs/meta/inbox/<member>/<id>`. + Inbox { + /// The inbox action to run. + #[facet(args::subcommand)] + action: InboxAction, + }, + /// Record that `oid` was redacted (`refs/meta/redactions/<id>`), + /// refusing any future push that would refill it + /// (`receive.redaction-ingest`). Admin-only: the gate's default + /// namespace-authorization arm requires admin-registered provenance + /// for `refs/meta/redactions/*`. + Redact { + /// The object id to redact. + #[facet(args::positional)] + oid: String, + /// A human-readable reason recorded alongside the redaction. + #[facet(args::named)] + reason: String, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Plumbing invoked by git's own hooks on the single-node hosted root + /// (`git.ents.cloud`) — not part of the porcelain surface a developer + /// runs directly. + Hook { + /// Which hook is running. + #[facet(args::subcommand)] + action: HookAction, + }, +} + +/// `git ents members` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum MembersAction { + /// List the members recorded in this repository. + List, + /// Enroll a new member, or update an existing one's key. + Add { + /// The member's username (`refs/meta/member/<username>`). + #[facet(args::positional)] + username: String, + /// The public key to enroll (an OpenSSH single-line public key); + /// defaults to the signer's own public key. + #[facet(args::named)] + pubkey: Option<String>, + /// Key to sign the enrollment with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Remove a member, deleting its ref. + Remove { + /// The member (username) to remove. + #[facet(args::positional)] + username: String, + /// Key to sign the removal with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Revoke a member's key (`model.member-revocation`): the record + /// stays, but the key no longer authorizes new signatures. + Revoke { + /// The member (username) to revoke. + #[facet(args::positional)] + username: String, + /// Key to sign the revocation with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Lift a revocation, restoring a member's key to active. + Unrevoke { + /// The member (username) to unrevoke. + #[facet(args::positional)] + username: String, + /// Key to sign the unrevocation with; defaults to + /// `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Report whether a key is an active member. + Check { + /// Key to look for; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, +} + +/// `git ents account` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum AccountAction { + /// Create or update this repository's account identity. + Create { + /// The member this account belongs to; defaults to the signer's + /// own member (resolved by public key). + #[facet(args::named)] + member: Option<String>, + /// The login identity the member authenticates as. + #[facet(args::named)] + login: String, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, +} + +/// `git ents effect` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum EffectAction { + /// List the effects configured in this repository. + List, + /// Show one effect's definition and, when a commit is given, its + /// result. + Show { + /// The effect's name. + #[facet(args::positional)] + name: String, + /// Commit to show the result for. + #[facet(args::named)] + at: Option<String>, + }, + /// Define (or replace) an effect and push the update. + Add { + /// Name to record the effect under (`refs/meta/effects/<name>`). + #[facet(args::positional)] + name: String, + /// The query this effect triggers on (`query.grammar`). + #[facet(args::named)] + on: String, + /// The command the effect runs. + #[facet(args::positional)] + run: String, + /// Toolchain (`refs/meta/toolchains/<name>`) to activate before + /// the command runs (repeatable). + #[facet(args::named, args::label = "TOOLCHAIN", default)] + toolchain: Vec<String>, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Run this repository's effects locally against every commit still + /// owed a result, or a single one with `--at` + /// (`effect.local-run`): identical toolchain materialization and + /// sandbox path to a hosted worker, the queue skipped entirely. + Run { + /// The effect's name. + #[facet(args::positional)] + name: String, + /// Commit to run against; omit to run every outstanding commit + /// (`query.workset`). + #[facet(args::named)] + at: Option<String>, + /// Key to sign the result with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Show recorded results for an effect, newest first. + Log { + /// The effect's name. + #[facet(args::positional)] + name: String, + }, +} + +/// `git ents toolchain` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum ToolchainAction { + /// Import a local directory as toolchain `name`, embedding its + /// contents whole (`ents_effect::Recipe::Embedded`). + Import { + /// Name to record the toolchain under + /// (`refs/meta/toolchains/<name>`). + #[facet(args::positional)] + name: String, + /// Directory of executables to import, activated on `PATH` when + /// an effect declares this toolchain. + #[facet(args::positional)] + bin: PathBuf, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Show a toolchain's provenance. + View { + /// Name (`refs/meta/toolchains/<name>`) to view. + #[facet(args::positional)] + name: String, + }, + /// Show a toolchain's import history — the ref's own commit log. + Log { + /// Name (`refs/meta/toolchains/<name>`) to show history for. + #[facet(args::positional)] + name: String, + }, +} + +/// `git ents comment` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum CommentAction { + /// Anchor a comment to a file at a revision. + Add { + /// Repository-relative path of the file the comment anchors to. + #[facet(args::positional)] + path: String, + /// The comment's body text. + #[facet(args::named)] + body: 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, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, + /// Show one comment: its anchor, projected onto a revision, and its + /// body. + Show { + /// The comment's id. + #[facet(args::positional)] + id: String, + /// Revision to project the comment's anchor onto. + #[facet(args::named, default = "HEAD")] + rev: String, + }, +} + +/// `git ents inbox` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum InboxAction { + /// List entities awaiting adoption. + List, + /// Adopt an inbox entity onto its canonical ref + /// (`sync.adoption-machinery`): a merge that keeps the author's + /// original signed commit in ancestry + /// (`sync.adoption-no-cherry-pick`). + Adopt { + /// The inbox entry to adopt, as `<member>/<id>`. + #[facet(args::positional)] + entry: String, + /// Key to sign the adoption merge with; defaults to + /// `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, +} + +/// Plumbing subcommands the single-node hosted root's git hooks invoke; +/// see `crate::hook`'s own doc for what each does and why. +#[derive(Facet)] +#[repr(u8)] +pub enum HookAction { + /// Run as git's own `pre-receive` hook: evaluate the gate against + /// every proposed transition read from stdin, refusing the whole + /// push under the mandatory gate if any fails. + PreReceive, + /// Run as git's own `post-receive` hook: reconcile outstanding effect + /// obligations (`receive.reconstructible`) and run them. + PostReceive, + /// Reconcile outstanding effect obligations without running anything + /// — the boot-time scan on its own, for operational use and testing. + Reconcile, +}
crates/git-ents/src/commands/account.rs @@ -1,0 +1,64 @@ +//! `git ents account create`: link a member to a login identity at the +//! fixed `refs/meta/account` ref (`model.account`). + +use ents_model::{Account, MemberId, namespace}; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_entity}; +use crate::root::LocalRoot; + +/// Run `git ents account create`. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `member` is given but no such member exists (or, +/// when omitted, the signer's own key is not enrolled yet — enroll it with +/// `git ents members add` first); otherwise see +/// [`crate::mutate::outcome_to_result`]. +pub fn create( + root: &LocalRoot, + member: Option<String>, + login: String, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let member_id = match member { + Some(username) => MemberId::new(username), + None => { + let (username, _) = + super::members::check(root, None)?.ok_or_else(|| Error::NotFound { + what: "member for the current signing key".to_owned(), + })?; + MemberId::new(username) + } + }; + let account = Account { + member: member_id, + login, + }; + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal" + )] + let name: gix::refs::FullName = namespace::ACCOUNT_REF + .try_into() + .expect("fixed, valid refname"); + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + name, + &account, + &identity, + "Create account", + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +}
crates/git-ents/src/commands/comment.rs @@ -1,0 +1,101 @@ +//! `git ents comment`: anchor a comment to code and show it back, +//! projected onto a revision (`model.comment`, `anchor.definition`, +//! `anchor.projection`). + +use ents_anchor::{Anchor, LineRange, Projection, project, snippet}; +use ents_model::{Comment, namespace}; +use facet_git_tree::RawTree; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_entity}; +use crate::root::LocalRoot; + +/// `git ents comment add`: anchor `body` to `path` (optionally `lines`) at +/// `rev`. +/// +/// # Errors +/// +/// [`Error::InvalidArgument`] if `lines` does not parse as `<start>[:<end>]`; +/// otherwise propagates capture, serialization, or `receive` failures. +pub fn add( + root: &LocalRoot, + path: &str, + body: String, + lines: Option<String>, + rev: &str, + key: Option<std::path::PathBuf>, +) -> Result<String> { + let repo = gix::open(&root.path)?; + let range = lines.map(|text| parse_line_range(&text)).transpose()?; + let anchor = ents_anchor::capture(&repo, rev, path, range)?; + + let anchor_tree = facet_git_tree::serialize_into(&anchor, &root.objects)?; + let comment = Comment { + body, + anchor: RawTree::new(anchor_tree), + }; + + // The comment's id is its own genesis tip's short oid, known only once + // the commit is built — `propose_entity` builds it internally, so this + // command derives the ref name from a locally generated id instead + // (`meta-ref.granularity`: one ref per comment). + let id = uuid::Uuid::new_v4().simple().to_string(); + let ref_name = namespace::comment_ref(&id)?; + + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + ref_name, + &comment, + &identity, + &format!("Comment on {path}"), + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(id) +} + +/// `git ents comment show`: `id`'s anchor (projected onto `rev`), anchored +/// text, and body. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `id` has no comment ref. +pub fn show(root: &LocalRoot, id: &str, rev: &str) -> Result<(Comment, Anchor, Projection)> { + let ref_name = namespace::comment_ref(id)?; + let Some(tip) = root.refs.get(ref_name.as_ref())? else { + return Err(Error::NotFound { + what: format!("comment {id}"), + }); + }; + let tree = super::commit_tree(&root.objects, tip)?; + let comment = facet_git_tree::deserialize::<Comment>(&tree, &root.objects)?; + let anchor = facet_git_tree::deserialize::<Anchor>(&comment.anchor.oid(), &root.objects)?; + + let repo = gix::open(&root.path)?; + let projection = project(&repo, &anchor, rev)?; + let _ = snippet(&anchor)?; // Confirm the anchored text still reads back. + Ok((comment, anchor, projection)) +} + +fn parse_line_range(text: &str) -> Result<LineRange> { + let (start, end) = match text.split_once(':') { + Some((s, e)) => (s, e), + None => (text, text), + }; + let start: u64 = start + .parse() + .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?; + let end: u64 = end + .parse() + .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?; + Ok(LineRange { start, end }) +}
crates/git-ents/src/commands/effect.rs @@ -1,0 +1,221 @@ +//! `git ents effect`: define, list, show, run, and log effects +//! (`model.effect-definition`, `effect.local-run`). + +use ents_effect::run::{run_effect, short_oid}; +use ents_model::{Effect, Status, namespace}; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_entity}; +use crate::root::LocalRoot; + +/// `git ents effect list`: every effect currently defined. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, Effect)>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/effects/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let Some(short) = path.strip_prefix("refs/meta/effects/") else { + continue; + }; + if short.is_empty() || short.contains('/') { + continue; + } + let tree = super::commit_tree(&root.objects, tip)?; + if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &root.objects) { + out.push((short.to_owned(), effect)); + } + } + Ok(out) +} + +/// `git ents effect add`: define (or replace) `name`. +/// +/// # Errors +/// +/// See [`crate::mutate::outcome_to_result`]. +pub fn add( + root: &LocalRoot, + name: &str, + on: String, + run: String, + toolchains: Vec<String>, + key: Option<std::path::PathBuf>, +) -> Result<()> { + // Validate the trigger parses before it is ever written — a malformed + // trigger would otherwise be silently skipped by every future + // reconciliation scan (`ents_receive::reconcile`'s own tolerance rule). + let _: ents_query::Query = on + .parse() + .map_err(|_source| Error::InvalidArgument(format!("unparsable trigger: {on}")))?; + + let signer = signer(root, key)?; + let effect = Effect { + trigger: on, + toolchains, + run, + }; + let ref_name = namespace::effect_ref(name)?; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + ref_name, + &effect, + &identity, + &format!("Define effect {name}"), + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// `git ents effect show`: the definition, plus its result at `at` when +/// given. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `name` has no effect definition. +pub fn show(root: &LocalRoot, name: &str, at: Option<String>) -> Result<(Effect, Option<Status>)> { + let ref_name = namespace::effect_ref(name)?; + let Some(tip) = root.refs.get(ref_name.as_ref())? else { + return Err(Error::NotFound { + what: format!("effect {name}"), + }); + }; + let tree = super::commit_tree(&root.objects, tip)?; + let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?; + + let status = match at { + None => None, + Some(commit) => { + let oid = resolve_commit(root, &commit)?; + let results_ref = namespace::result_ref(name, &short_oid(oid))?; + match root.refs.get(results_ref.as_ref())? { + None => None, + Some(result_tip) => { + let tree = super::commit_tree(&root.objects, result_tip)?; + facet_git_tree::deserialize::<Status>(&tree, &root.objects).ok() + } + } + } + }; + Ok((effect, status)) +} + +/// `git ents effect run`: run `name` locally against every outstanding +/// commit, or a single `at` — no queue, identical materialization and +/// sandbox path to a hosted worker (`effect.local-run`). +/// +/// # Errors +/// +/// Propagates any failure `ents_effect::run::run_effect` reports. +#[expect( + clippy::result_large_err, + reason = "the closure passed to run_effect below is typed against ents_effect::Error, that \ + crate's own Result shape, not this crate's to box" +)] +pub fn run( + root: &LocalRoot, + name: &str, + at: Option<String>, + key: Option<std::path::PathBuf>, + executor: &dyn ents_effect::Executor, +) -> Result<Vec<(gix_hash::ObjectId, ents_receive::Outcome)>> { + let ref_name = namespace::effect_ref(name)?; + let Some(tip) = root.refs.get(ref_name.as_ref())? else { + return Err(Error::NotFound { + what: format!("effect {name}"), + }); + }; + let tree = super::commit_tree(&root.objects, tip)?; + let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?; + + let signer = signer(root, key)?; + let at_oid = at.map(|rev| resolve_commit(root, &rev)).transpose()?; + + let scratch = tempfile::tempdir().map_err(|source| Error::Io { + path: root.path.clone(), + source, + })?; + let cache = tempfile::tempdir().map_err(|source| Error::Io { + path: root.path.clone(), + source, + })?; + + let author = actor(&signer); + let outcomes = run_effect( + &root.refs, + &root.objects, + &root.events, + executor, + scratch.path(), + cache.path(), + name, + &effect, + at_oid, + |short| canonical_result_ref(name, short), + &author, + &|payload| signer.sign(payload), + root.mode(), + )?; + Ok(outcomes) +} + +/// Build the canonical results refname for one run, in the shape +/// `run_effect`'s own `results_ref` parameter expects. +/// +/// # Errors +/// +/// Never in practice: `name` is an already-defined effect and `short` is +/// always a hex oid slice ([`ents_effect::run::short_oid`]'s own shape), so +/// both always compose into a well-formed refname; kept fallible only +/// because `ents_effect::run::run_effect`'s own signature requires it. +#[expect( + clippy::result_large_err, + reason = "the Result shape is ents_effect::run_effect's own signature, not this crate's to box" +)] +fn canonical_result_ref(name: &str, short: &str) -> ents_effect::Result<gix::refs::FullName> { + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "see this function's own doc: always well-formed in practice" + )] + Ok(namespace::result_ref(name, short).expect("well-formed refname segments")) +} + +/// `git ents effect log`: every recorded result for `name`, newest first — +/// the results ref's own commit log. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `name` has no results yet. +pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, Status)>> { + let prefix = format!("refs/meta/results/{name}/"); + let mut out = Vec::new(); + for entry in root.refs.iter_prefix(&prefix)? { + let (_, tip) = entry?; + let tree = super::commit_tree(&root.objects, tip)?; + if let Ok(status) = facet_git_tree::deserialize::<Status>(&tree, &root.objects) { + out.push((tip, status)); + } + } + Ok(out) +} + +fn resolve_commit(root: &LocalRoot, rev: &str) -> Result<gix_hash::ObjectId> { + let repo = gix::open(&root.path)?; + let id = repo + .rev_parse_single(rev) + .map_err(|source| Error::InvalidArgument(format!("cannot resolve {rev}: {source}")))?; + Ok(id.detach()) +}
crates/git-ents/src/commands/inbox.rs @@ -1,0 +1,105 @@ +//! `git ents inbox`: list entities awaiting adoption and adopt them onto +//! their canonical ref (`sync.adoption-machinery`, +//! `sync.adoption-no-cherry-pick`). + +use ents_model::namespace; +use ents_sync::resolve::{Heads, Merged, merge_heads}; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::outcome_to_result; +use crate::root::LocalRoot; + +/// `git ents inbox list`: every `refs/meta/inbox/<member>/<id>` entry. +/// +/// # Errors +/// +/// Propagates a ref-store read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<String>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/inbox/")? { + let (name, _) = entry?; + let path = name.as_bstr().to_string(); + if let Some(rest) = path.strip_prefix("refs/meta/inbox/") { + out.push(rest.to_owned()); + } + } + Ok(out) +} + +/// `git ents inbox adopt`: fold `entry` (`<member>/<id>`) onto its +/// canonical ref (`refs/meta/<id>`) via [`merge_heads`], keeping the +/// author's original signed commit in ancestry +/// (`sync.adoption-no-cherry-pick`). +/// +/// # Errors +/// +/// [`Error::NotFound`] if `entry` has no inbox ref; [`Error::InvalidArgument`] +/// on a merge conflict (a human must resolve it before adoption can +/// complete — this phase does not implement interactive conflict +/// resolution); otherwise see [`crate::mutate::outcome_to_result`]. +pub fn adopt(root: &LocalRoot, entry: &str, key: Option<std::path::PathBuf>) -> Result<()> { + let Some((member, id)) = entry.split_once('/') else { + return Err(Error::InvalidArgument(format!( + "expected <member>/<id>, got {entry:?}" + ))); + }; + let inbox_ref = namespace::inbox_ref(&ents_model::MemberId::new(member), id)?; + let Some(theirs) = root.refs.get(inbox_ref.as_ref())? else { + return Err(Error::NotFound { + what: format!("inbox entry {entry}"), + }); + }; + let canonical: gix::refs::FullName = format!("refs/meta/{id}") + .try_into() + .map_err(|_source| Error::InvalidArgument(format!("bad canonical ref for {id}")))?; + let ours = root.refs.get(canonical.as_ref())?; + + let signer = signer(root, key)?; + let author = actor(&signer); + let heads = Heads { + refname: canonical.clone(), + ours, + theirs, + }; + let merged = merge_heads( + &root.objects, + &heads, + &author, + &format!("Adopt {entry}"), + |payload| signer.sign(payload), + )?; + let tip = match merged { + Merged::Tip(tip) => tip, + Merged::Conflict(paths) => { + let rendered = paths + .iter() + .map(|p| p.to_string()) + .collect::<Vec<_>>() + .join(", "); + return Err(Error::InvalidArgument(format!( + "adoption conflict at: {rendered}" + ))); + } + }; + + let proposal = ents_receive::Proposal { + transitions: vec![ents_receive::RefTransition { + name: canonical, + old: ours, + new: Some(tip), + }], + objects: vec![tip], + auth: None, + }; + let outcome = ents_receive::receive( + &root.refs, + &root.objects, + &root.events, + &proposal, + root.mode(), + )?; + outcome_to_result(outcome, ours)?; + Ok(()) +}
crates/git-ents/src/commands/members.rs @@ -1,0 +1,151 @@ +//! `git ents members`: enroll, remove, revoke, unrevoke, and check members +//! (`model.member-identity`, `model.member-revocation`). + +use ents_model::{Member, MemberId, MemberState, Provenance, namespace}; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_delete, propose_entity}; +use crate::root::LocalRoot; + +/// `git ents members list`: every member ref and its current state. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, Member)>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/member/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let Some(username) = path.strip_prefix("refs/meta/member/") else { + continue; + }; + if let Some(member) = read_member(root, tip)? { + out.push((username.to_owned(), member)); + } + } + Ok(out) +} + +/// `git ents members add`: enroll `username` with `pubkey` (or the +/// signer's own public key), admin-registered. +/// +/// # Errors +/// +/// Propagates a signing, serialization, or `receive` failure; see +/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders. +pub fn add( + root: &LocalRoot, + username: &str, + pubkey: Option<String>, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let pubkey = pubkey.unwrap_or_else(|| signer.public_openssh()); + let member = Member::new(pubkey, Provenance::AdminRegistered); + let name = namespace::member_ref(&MemberId::new(username))?; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + name, + &member, + &identity, + &format!("Enroll {username}"), + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// `git ents members remove`: delete `username`'s ref entirely. +/// +/// # Errors +/// +/// See [`add`]. +pub fn remove(root: &LocalRoot, username: &str, key: Option<std::path::PathBuf>) -> Result<()> { + let signer = signer(root, key)?; + let name = namespace::member_ref(&MemberId::new(username))?; + let outcome = propose_delete(&root.refs, &root.objects, &root.events, name, root.mode())?; + let _ = signer; // signing material is not needed for a deletion transition. + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// `git ents members revoke`/`unrevoke`: flip `username`'s +/// [`MemberState`] without deleting the record (`model.member-revocation`). +/// +/// # Errors +/// +/// [`Error::NotFound`] if `username` has no member ref; otherwise see +/// [`add`]. +pub fn set_revoked( + root: &LocalRoot, + username: &str, + revoked: bool, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let name = namespace::member_ref(&MemberId::new(username))?; + let Some(tip) = root.refs.get(name.as_ref())? else { + return Err(Error::NotFound { + what: format!("member {username}"), + }); + }; + let mut member = read_member(root, tip)?.ok_or_else(|| Error::NotFound { + what: format!("member {username}"), + })?; + member.state = if revoked { + MemberState::Revoked + } else { + MemberState::Active + }; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let verb = if revoked { "Revoke" } else { "Unrevoke" }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + name, + &member, + &identity, + &format!("{verb} {username}"), + root.mode(), + )?; + outcome_to_result(outcome, Some(tip))?; + Ok(()) +} + +/// `git ents members check`: whether `key` (or the resolved signing key) +/// names an active member, and which username. +/// +/// # Errors +/// +/// Propagates a signing-key or ref-store read failure. +pub fn check( + root: &LocalRoot, + key: Option<std::path::PathBuf>, +) -> Result<Option<(String, MemberState)>> { + let signer = signer(root, key)?; + let pubkey = signer.public_openssh(); + for (username, member) in list(root)? { + if member.key == pubkey { + return Ok(Some((username, member.state))); + } + } + Ok(None) +} + +fn read_member(root: &LocalRoot, tip: gix_hash::ObjectId) -> Result<Option<Member>> { + let tree = crate::commands::commit_tree(&root.objects, tip)?; + Ok(facet_git_tree::deserialize::<Member>(&tree, &root.objects).ok()) +}
crates/git-ents/src/commands/mod.rs @@ -1,0 +1,116 @@ +//! One module per `git ents` subcommand family — [`crate::cli`]'s +//! definitions given a body. Each function here is a thin caller into a +//! library crate: [`crate::exe`] dispatches to these, never the other way +//! around, so the same logic is callable from a test without a terminal. +#![expect( + clippy::let_underscore_must_use, + reason = "rendering an advisory-gate verdict to a writer is best-effort; a broken pipe here \ + is not actionable" +)] + +pub mod account; +pub mod comment; +pub mod effect; +pub mod inbox; +pub mod members; +pub mod redact; +pub mod setup; +pub mod toolchain; + +use std::io::Write; +use std::path::PathBuf; + +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind}; + +use crate::error::{Error, Result}; +use crate::root::LocalRoot; +use crate::sign::Signer; + +/// The tree of the commit at `oid` — every command that reads back a typed +/// entity needs this, and neither `ents_receive` nor `ents_effect` exports +/// their own copy publicly, so it is a small, shared utility here rather +/// than duplicated per command module. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `oid` is missing or not a commit. +pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::InvalidArgument(source.to_string()))? + .ok_or_else(|| Error::NotFound { + what: oid.to_string(), + })?; + if data.kind != Kind::Commit { + return Err(Error::NotFound { + what: oid.to_string(), + }); + } + let commit = CommitRef::from_bytes(data.data, oid.kind()) + .map_err(|source| Error::InvalidArgument(source.to_string()))?; + Ok(commit.tree()) +} + +/// Resolve `--key` (or the repository's `user.signingkey`, or the default +/// `~/.ssh/id_ed25519`) into a loaded [`Signer`] — the one place every +/// write-side command turns an optional key path into a usable identity. +/// +/// # Errors +/// +/// See [`crate::sign::resolve_key_path`] and [`Signer::load`]. +pub fn signer(root: &LocalRoot, key: Option<PathBuf>) -> Result<Signer> { + let repo = gix::open(&root.path)?; + let path = crate::sign::resolve_key_path(&repo, key.as_deref())?; + Signer::load(&path) +} + +/// The commit author/committer signature every mutation this CLI produces +/// carries: the current wall-clock time, under a fixed name/email derived +/// from the signer's own key fingerprint (this crate never depends on +/// `user.name`/`user.email` being configured, mirroring +/// `gix-ref-store`'s own reflog-identity rationale). +#[must_use] +pub fn actor(signer: &Signer) -> gix::actor::Signature { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or_default(); + gix::actor::Signature { + name: "git-ents".into(), + email: format!("{}@git-ents.local", short_fingerprint(signer)).into(), + time: gix::date::Time { seconds, offset: 0 }, + } +} + +fn short_fingerprint(signer: &Signer) -> String { + let key = signer.public_openssh(); + let hex = key + .split_whitespace() + .nth(1) + .unwrap_or(&key) + .chars() + .take(12) + .collect::<String>(); + if hex.is_empty() { + "member".to_owned() + } else { + hex + } +} + +/// Print `verdicts` (`gate.verdict-reason`) for a command that succeeded +/// under the advisory gate but still wants to surface a non-passing +/// verdict to the user, mirroring `sync.local-advisory`: a failing verdict +/// here is information, never a block. +pub fn render_verdicts( + out: &mut impl Write, + verdicts: &[(gix::refs::FullName, ents_gate::Verdict)], +) { + for (name, verdict) in verdicts { + if let ents_gate::Verdict::Fail(refusal) = verdict { + let _ = writeln!(out, "warning: {name}: {refusal}", name = name.as_bstr()); + } + } +}
crates/git-ents/src/commands/redact.rs @@ -1,0 +1,54 @@ +//! `git ents redact`: record that an object was redacted +//! (`model.redaction`), refusing any future push that would refill it +//! (`receive.redaction-ingest`). + +use ents_model::{Redaction, namespace}; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_entity}; +use crate::root::LocalRoot; + +/// Run `git ents redact <oid> --reason ...`. +/// +/// The record lands at `refs/meta/redactions/<id>`; the gate's default +/// namespace-authorization arm requires admin-registered provenance for +/// this namespace, so a non-admin signer is refused here exactly as any +/// other call site would refuse it (`gate.call-sites`, +/// `receive.redaction-admin-only`). +/// +/// # Errors +/// +/// [`Error::InvalidArgument`] if `oid` does not parse as an object id; +/// otherwise see [`crate::mutate::outcome_to_result`]. +pub fn run( + root: &LocalRoot, + oid: &str, + reason: String, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let target: gix_hash::ObjectId = oid + .parse() + .map_err(|_source| Error::InvalidArgument(format!("not an object id: {oid}")))?; + let redaction = Redaction::new(target, reason); + let id = target.to_string(); + let ref_name = namespace::redaction_ref(&id)?; + + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + ref_name, + &redaction, + &identity, + &format!("Redact {id}"), + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +}
crates/git-ents/src/commands/setup.rs @@ -1,0 +1,118 @@ +//! `git ents setup`: resolve or generate a signing key, record it as this +//! repository's `user.signingkey` with `gpg.format=ssh`, and set +//! `receive.denyCurrentBranch=updateInstead` (`roots.worktree-update`). +//! +//! `receive.denyCurrentBranch=updateInstead` is the integration-test +//! harness edge case `roots.worktree-update` names: it lets an external +//! push land on this repository's checked-out branch and still update the +//! working tree, which is not how a normal git remote behaves and is never +//! needed for `refs/meta/*` traffic (which never touches a worktree at +//! all). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use rand_core::OsRng; +use ssh_key::{Algorithm, LineEnding, PrivateKey}; + +use crate::error::{Error, Result}; +use crate::root::LocalRoot; +use crate::sign::Signer; + +/// Run `git ents setup` against `root`: resolve `key`, generating a new +/// `~/.ssh/id_ed25519` if neither `key` nor `user.signingkey` resolves to +/// an existing file, then write `user.signingkey`, `gpg.format=ssh`, and +/// `receive.denyCurrentBranch=updateInstead` to the repository's own +/// (local) config. +/// +/// # Errors +/// +/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded; +/// [`Error::Io`] if generating or writing a new key fails; propagates a +/// config-write failure. +pub fn run(root: &LocalRoot, key: Option<PathBuf>) -> Result<PathBuf> { + let repo = gix::open(&root.path)?; + let resolved = match crate::sign::resolve_key_path(&repo, key.as_deref()) { + Ok(path) if path.exists() => path, + Ok(path) => generate_key(&path)?, + Err(Error::NoSigningKey) => { + let default = default_key_path()?; + generate_key(&default)? + } + Err(other) => return Err(other), + }; + // Confirm the resolved key actually loads before recording it. + Signer::load(&resolved)?; + + // `gix`'s own config-snapshot API (`config_snapshot_mut`) has no + // file-persistence path at all: `SnapshotMut::commit` only updates the + // in-memory resolved view this `Repository` handle holds, never + // `.git/config` on disk (confirmed empirically — a value written that + // way is invisible to a subsequent, separate `git config` read). + // Writing durable local config is therefore delegated to `git config` + // itself here, same as `pre-redo`'s own client setup did; it is not + // part of the ref/object CAS discipline the rest of this crate is + // strict about (`arch.loose-cas-discipline` governs refs, not plain + // config values). + let path_str = resolved.to_string_lossy().into_owned(); + for (key, value) in [ + ("user.signingkey", path_str.as_str()), + ("gpg.format", "ssh"), + ("receive.denyCurrentBranch", "updateInstead"), + ] { + set_local_config(&root.path, key, value)?; + } + + Ok(resolved) +} + +/// Set `key` to `value` in `repo_path`'s own local config via `git config`. +fn set_local_config(repo_path: &Path, key: &str, value: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["config", "--local", key, value]) + .output() + .map_err(|source| Error::Io { + path: repo_path.to_owned(), + source, + })?; + if !output.status.success() { + return Err(Error::BadSigningKey { + path: repo_path.to_owned(), + detail: format!( + "git config --local {key} {value} failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + }); + } + Ok(()) +} + +/// Generate a fresh, unencrypted ed25519 key at `path` (creating parent +/// directories as needed) and return `path` unchanged. +fn generate_key(path: &Path) -> Result<PathBuf> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| Error::Io { + path: parent.to_owned(), + source, + })?; + } + let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|source| { + Error::BadSigningKey { + path: path.to_owned(), + detail: source.to_string(), + } + })?; + key.write_openssh_file(path, LineEnding::LF) + .map_err(|source| Error::BadSigningKey { + path: path.to_owned(), + detail: source.to_string(), + })?; + Ok(path.to_owned()) +} + +fn default_key_path() -> Result<PathBuf> { + let home = std::env::var_os("HOME").ok_or(Error::NoSigningKey)?; + Ok(PathBuf::from(home).join(".ssh").join("id_ed25519")) +}
crates/git-ents/src/commands/toolchain.rs @@ -1,0 +1,169 @@ +//! `git ents toolchain`: import a local `bin/` directory as an embedded +//! toolchain manifest, view its provenance, and show its import history +//! (`model.toolchain`, `effect.toolchains`). +//! +//! Only [`ents_effect::Recipe::Embedded`] is wired here (`--from` recipes — +//! `rustup`, `sccache`, `url` — are `pre-redo` extras this phase's spec +//! does not name; deferred, see this crate's final report). + +use std::path::Path; + +use ents_effect::Recipe; +use ents_model::{Toolchain, namespace}; +use gix_object::Tree; +use gix_object::tree::{Entry, EntryKind}; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::{Identity, outcome_to_result, propose_entity}; +use crate::root::LocalRoot; + +/// `git ents toolchain import`: embed `bin` whole as toolchain `name`. +/// +/// # Errors +/// +/// [`Error::Io`] if `bin` cannot be walked; otherwise see +/// [`crate::mutate::outcome_to_result`]. +pub fn import( + root: &LocalRoot, + name: &str, + bin: &Path, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let tree = write_dir_as_tree(bin, &root.objects)?; + let recipe = Recipe::Embedded { tree }; + let toolchain = Toolchain { + name: name.to_owned(), + recipe: recipe.render(), + }; + let signer = signer(root, key)?; + let ref_name = namespace::toolchain_ref(name)?; + let identity = Identity { + actor: actor(&signer), + signer: &signer, + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + ref_name, + &toolchain, + &identity, + &format!("Import toolchain {name}"), + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// `git ents toolchain view`: the toolchain's recorded recipe. +/// +/// # Errors +/// +/// Propagates [`ents_effect::toolchain::resolve`]'s own errors. +pub fn view(root: &LocalRoot, name: &str) -> Result<(Toolchain, Recipe)> { + Ok(ents_effect::toolchain::resolve( + &root.refs, + &root.objects, + name, + )?) +} + +/// `git ents toolchain log`: every past import, newest first — the ref's +/// own commit log. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `name` has no toolchain ref. +pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<gix_hash::ObjectId>> { + let ref_name = namespace::toolchain_ref(name)?; + let repo = gix::open(&root.path)?; + let Some(tip) = root.refs.get(ref_name.as_ref())? else { + return Err(Error::NotFound { + what: format!("toolchain {name}"), + }); + }; + let mut out = Vec::new(); + let mut next = Some(tip); + while let Some(oid) = next { + out.push(oid); + let commit = repo + .find_object(oid) + .map_err(|source| Error::InvalidArgument(source.to_string()))? + .try_into_commit() + .map_err(|_source| Error::InvalidArgument(format!("{oid} is not a commit")))?; + next = commit.parent_ids().next().map(|id| id.detach()); + } + Ok(out) +} + +/// Recursively write `dir`'s contents into `objects` as a tree, preserving +/// the executable bit and recursing into subdirectories — the inverse of +/// `ents_effect::materialize::checkout`. Symlinks and anything that is not +/// a plain file or directory are refused (`anchor.retention`-style +/// defensiveness: a toolchain import should never silently embed something +/// that cannot round-trip through a tree). +/// +/// # Errors +/// +/// [`Error::Io`] on a read failure or an unsupported entry kind. +fn write_dir_as_tree(dir: &Path, objects: &impl gix_object::Write) -> Result<gix_hash::ObjectId> { + let mut entries = Vec::new(); + let read = std::fs::read_dir(dir).map_err(|source| Error::Io { + path: dir.to_owned(), + source, + })?; + for item in read { + let item = item.map_err(|source| Error::Io { + path: dir.to_owned(), + source, + })?; + let file_type = item.file_type().map_err(|source| Error::Io { + path: item.path(), + source, + })?; + let filename = item.file_name().to_string_lossy().into_owned(); + let (mode, oid) = if file_type.is_dir() { + (EntryKind::Tree, write_dir_as_tree(&item.path(), objects)?) + } else if file_type.is_file() { + let bytes = std::fs::read(item.path()).map_err(|source| Error::Io { + path: item.path(), + source, + })?; + let executable = is_executable(&item.path()); + let kind = if executable { + EntryKind::BlobExecutable + } else { + EntryKind::Blob + }; + let oid = objects.write_buf(gix_object::Kind::Blob, &bytes)?; + (kind, oid) + } else { + return Err(Error::Io { + path: item.path(), + source: std::io::Error::other("unsupported entry (symlink or special file)"), + }); + }; + entries.push(Entry { + mode: mode.into(), + filename: filename.into(), + oid, + }); + } + entries.sort(); + Ok(objects.write(&Tree { entries })?) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(path) + .map(|meta| meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(_path: &Path) -> bool { + false +}
crates/git-ents/src/error.rs @@ -1,0 +1,163 @@ +//! The porcelain-wide error type: every subcommand's failure, rendered for +//! a terminal. + +use std::path::PathBuf; + +/// Every way a `git-ents` subcommand can fail. +/// +/// Each variant documents when it occurs and what the user should do — +/// this is the only layer that renders a failure for a human, so the +/// detail belongs here rather than in a library crate's own error type. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The current directory is not inside a git repository, or the + /// discovered repository has no `.git` directory `git-ents` can open. + /// Run the command inside a git repository. + #[error("not a git repository (or any parent up to mount point): {path}")] + NotARepo { + /// The directory `git-ents` started looking from. + path: PathBuf, + }, + + /// No signing key could be resolved: `--key` was not given, + /// `user.signingkey` is unset, and no default key exists at + /// `~/.ssh/id_ed25519`. Run `git ents setup` first. + #[error("no signing key configured; run `git ents setup` or pass --key")] + NoSigningKey, + + /// The signing key at `path` could not be read as an OpenSSH private + /// key, or is passphrase-protected (unsupported in this phase: use an + /// unencrypted key, or load one via `ssh-agent` in a future phase). + #[error("cannot use signing key at {path}: {detail}")] + BadSigningKey { + /// The key file that failed to load. + path: PathBuf, + /// What went wrong. + detail: String, + }, + + /// The gate refused the proposed mutation (`gate.verdict-reason`): the + /// refusal's own rendering names the rule and offers the inbox + /// alternative when one applies. + #[error("rejected: {0}")] + Refused(String), + + /// `receive` rejected the batch as a stale compare-and-swap: another + /// writer moved a ref between read and write. Retry the command. + #[error("rejected: {name} changed concurrently, retry")] + Stale { + /// The ref whose precondition was stale. + name: String, + }, + + /// A previously redacted object would have been refilled by this + /// mutation (`receive.redaction-ingest`); the mutation is refused. + #[error("refused: object {oid} was redacted and cannot be refilled")] + Redacted { + /// The redacted object id. + oid: gix_hash::ObjectId, + }, + + /// The named entity (member, effect, toolchain, comment, inbox entry) + /// does not exist. + #[error("not found: {what}")] + NotFound { + /// What was being looked up. + what: String, + }, + + /// A local (non-git, non-gate) I/O failure: reading or writing a file + /// outside the object database. + #[error("io error at {path}: {source}")] + Io { + /// The path being read or written. + path: PathBuf, + /// The underlying I/O failure. + #[source] + source: std::io::Error, + }, + + /// A malformed command-line argument that passed `figue`'s own parsing + /// but fails a semantic check this crate makes (an invalid line range, + /// an unparsable oid, ...). + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// Opening or reading the local git repository failed. Boxed (like the + /// other large variants below): `gix::open::Error` is large enough on + /// its own to trip `clippy::result_large_err` for every fallible + /// function in this crate if stored inline, the same reasoning + /// `ents-effect`'s own error type documents for its boxed + /// `ents_receive::Error` variant. + #[error(transparent)] + Repo(Box<gix::open::Error>), + + /// A `gix-ref-store` failure: reading or writing a ref. + #[error(transparent)] + Refs(#[from] gix_ref_store::Error), + + /// An `ents-gate` failure: the gate itself could not reach a verdict + /// (a store or object read failed), distinct from a reached refusal. + #[error(transparent)] + Gate(#[from] ents_gate::Error), + + /// An `ents-receive` failure: `receive` itself could not reach an + /// outcome. Boxed; see [`Error::Repo`]'s own doc. + #[error(transparent)] + Receive(Box<ents_receive::Error>), + + /// An `ents-effect` failure: toolchain resolution, materialization, or + /// the executor itself. Boxed; see [`Error::Repo`]'s own doc. + #[error(transparent)] + Effect(Box<ents_effect::Error>), + + /// An `ents-anchor` failure: capturing or projecting a code anchor. + #[error(transparent)] + Anchor(#[from] ents_anchor::Error), + + /// An `ents-sync` failure: pre-flight, routing, or merge. Boxed; see + /// [`Error::Repo`]'s own doc. + #[error(transparent)] + Sync(Box<ents_sync::Error>), + + /// An `ents-model` failure: building or validating a refname or typed + /// tree. + #[error(transparent)] + Model(#[from] ents_model::Error), + + /// A `facet-git-tree` (de)serialization failure. + #[error(transparent)] + Tree(#[from] facet_git_tree::Error), + + /// A raw object-store write failed (building a toolchain import's tree, + /// or a mutation commit). + #[error(transparent)] + ObjectWrite(#[from] gix_object::write::Error), +} + +impl From<gix::open::Error> for Error { + fn from(source: gix::open::Error) -> Self { + Self::Repo(Box::new(source)) + } +} + +impl From<ents_receive::Error> for Error { + fn from(source: ents_receive::Error) -> Self { + Self::Receive(Box::new(source)) + } +} + +impl From<ents_effect::Error> for Error { + fn from(source: ents_effect::Error) -> Self { + Self::Effect(Box::new(source)) + } +} + +impl From<ents_sync::Error> for Error { + fn from(source: ents_sync::Error) -> Self { + Self::Sync(Box::new(source)) + } +} + +/// This crate's `Result` alias. +pub type Result<T> = std::result::Result<T, Error>;
crates/git-ents/src/exe.rs @@ -1,0 +1,242 @@ +//! `git ents`'s dispatch: the only place a [`crate::cli::Top`] variant is +//! interpreted. Every branch is a thin call into [`crate::commands`]; no +//! business logic lives here. +#![expect( + clippy::let_underscore_must_use, + reason = "porcelain output to a writer (stdout in practice) is best-effort; a broken pipe \ + here is not actionable and every write is one-shot, not chained" +)] + +use crate::cli::{ + AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, MembersAction, + ToolchainAction, Top, +}; +use crate::commands; +use crate::error::Result; +use crate::root::{HostedRoot, LocalRoot}; + +/// Run `cli` against the repository discovered from the current +/// directory, writing porcelain output to `out`. +/// +/// # Errors +/// +/// Any [`crate::Error`] the dispatched command reports. +pub fn run(cli: Cli, out: &mut impl std::io::Write) -> Result<()> { + match cli.command { + Top::Setup { key } => { + let root = LocalRoot::discover(".")?; + let path = commands::setup::run(&root, key)?; + let _ = writeln!(out, "signing key: {}", path.display()); + Ok(()) + } + Top::Members { action } => run_members(action, out), + Top::Account { action } => run_account(action, out), + Top::Effect { action } => run_effect(action, out), + Top::Toolchain { action } => run_toolchain(action, out), + Top::Comment { action } => run_comment(action, out), + Top::Inbox { action } => run_inbox(action, out), + Top::Redact { oid, reason, key } => { + let root = LocalRoot::discover(".")?; + commands::redact::run(&root, &oid, reason, key)?; + let _ = writeln!(out, "redacted {oid}"); + Ok(()) + } + Top::Hook { action } => run_hook(action, out), + } +} + +fn run_members(action: MembersAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + MembersAction::List => { + for (username, member) in commands::members::list(&root)? { + let _ = writeln!( + out, + "{username}\t{:?}\t{:?}", + member.state, member.provenance + ); + } + } + MembersAction::Add { + username, + pubkey, + key, + } => { + commands::members::add(&root, &username, pubkey, key)?; + let _ = writeln!(out, "enrolled {username}"); + } + MembersAction::Remove { username, key } => { + commands::members::remove(&root, &username, key)?; + let _ = writeln!(out, "removed {username}"); + } + MembersAction::Revoke { username, key } => { + commands::members::set_revoked(&root, &username, true, key)?; + let _ = writeln!(out, "revoked {username}"); + } + MembersAction::Unrevoke { username, key } => { + commands::members::set_revoked(&root, &username, false, key)?; + let _ = writeln!(out, "unrevoked {username}"); + } + MembersAction::Check { key } => match commands::members::check(&root, key)? { + Some((username, state)) => { + let _ = writeln!(out, "{username}\t{state:?}"); + } + None => { + let _ = writeln!(out, "not a member"); + } + }, + } + Ok(()) +} + +fn run_account(action: AccountAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + AccountAction::Create { member, login, key } => { + commands::account::create(&root, member, login, key)?; + let _ = writeln!(out, "account created"); + } + } + Ok(()) +} + +fn run_effect(action: EffectAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + EffectAction::List => { + for (name, effect) in commands::effect::list(&root)? { + let _ = writeln!(out, "{name}\t{}", effect.trigger); + } + } + EffectAction::Show { name, at } => { + let (effect, status) = commands::effect::show(&root, &name, at)?; + let _ = writeln!(out, "trigger: {}", effect.trigger); + let _ = writeln!(out, "run: {}", effect.run); + let _ = writeln!(out, "result: {status:?}"); + } + EffectAction::Add { + name, + on, + run, + toolchain, + key, + } => { + commands::effect::add(&root, &name, on, run, toolchain, key)?; + let _ = writeln!(out, "defined {name}"); + } + EffectAction::Run { name, at, key } => { + let executor = ents_effect::DockerExecutor; + let outcomes = commands::effect::run(&root, &name, at, key, &executor)?; + for (oid, outcome) in outcomes { + let _ = writeln!(out, "{oid}\t{:?}", outcome.result); + } + } + EffectAction::Log { name } => { + for (oid, status) in commands::effect::log(&root, &name)? { + let _ = writeln!(out, "{oid}\t{status:?}"); + } + } + } + Ok(()) +} + +fn run_toolchain(action: ToolchainAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + ToolchainAction::Import { name, bin, key } => { + commands::toolchain::import(&root, &name, &bin, key)?; + let _ = writeln!(out, "imported {name}"); + } + ToolchainAction::View { name } => { + let (toolchain, recipe) = commands::toolchain::view(&root, &name)?; + let _ = writeln!(out, "name: {}", toolchain.name); + let _ = writeln!(out, "recipe: {recipe:?}"); + } + ToolchainAction::Log { name } => { + for oid in commands::toolchain::log(&root, &name)? { + let _ = writeln!(out, "{oid}"); + } + } + } + Ok(()) +} + +fn run_comment(action: CommentAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + CommentAction::Add { + path, + body, + lines, + rev, + key, + } => { + let id = commands::comment::add(&root, &path, body, lines, &rev, key)?; + let _ = writeln!(out, "commented {id}"); + } + CommentAction::Show { id, rev } => { + let (comment, anchor, projection) = commands::comment::show(&root, &id, &rev)?; + let _ = writeln!(out, "path: {}", anchor.path); + let _ = writeln!(out, "projection: {projection:?}"); + let _ = writeln!(out, "body: {}", comment.body); + } + } + Ok(()) +} + +fn run_inbox(action: InboxAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + InboxAction::List => { + for entry in commands::inbox::list(&root)? { + let _ = writeln!(out, "{entry}"); + } + } + InboxAction::Adopt { entry, key } => { + commands::inbox::adopt(&root, &entry, key)?; + let _ = writeln!(out, "adopted {entry}"); + } + } + Ok(()) +} + +fn run_hook(action: HookAction, out: &mut impl std::io::Write) -> Result<()> { + let root = HostedRoot::open(".")?; + match action { + HookAction::PreReceive => { + let stdin = std::io::stdin(); + crate::hook::pre_receive(&root, stdin.lock(), out) + } + HookAction::PostReceive => { + // Nothing to do: skip resolving a worker signing key and + // executor entirely rather than fail a repository that has + // not configured a hosted worker identity yet but also has no + // effects defined (the common case for a brand-new + // repository's very first pushes). + if root.events.pending().is_empty() { + let _ = writeln!(out, "ran 0 effect(s)"); + return Ok(()); + } + let scratch = tempfile::tempdir().map_err(|source| crate::Error::Io { + path: root.path.clone(), + source, + })?; + let cache = tempfile::tempdir().map_err(|source| crate::Error::Io { + path: root.path.clone(), + source, + })?; + let repo = gix::open(&root.path)?; + let key_path = crate::sign::resolve_key_path(&repo, None)?; + let signer = crate::sign::Signer::load(&key_path)?; + let executor = ents_effect::SpriteExecutor::new("git-ents-hosted-worker"); + let ran = + crate::hook::post_receive(&root, &executor, scratch.path(), cache.path(), &signer)?; + let _ = writeln!(out, "ran {ran} effect(s)"); + Ok(()) + } + HookAction::Reconcile => { + let _ = writeln!(out, "reconciled: {} pending", root.events.pending().len()); + Ok(()) + } + } +}
crates/git-ents/src/hook.rs @@ -1,0 +1,297 @@ +//! The single-node hosted root's git-hook plumbing. +//! +//! The development plan's phase-6 row doubles `git-ents` as `git.ents.cloud`: +//! loose refs and a real odb on a Fly volume, served behind *git's own* +//! `receive-pack` — "the same stock-git transport Phase 0 bootstraps, now +//! invoking `receive()` from a hook" — with an in-memory `EventSink`, a +//! boot-time reconciliation scan, and the Sprite executor. +//! +//! # Why the ref write itself is not `ents_receive::receive`'s +//! +//! `receive.unit`'s own doc says every mutation frontend, "the CLI, the +//! local UI, a hosted smart-HTTP hook", must call `receive` in-process, +//! with only the trait implementations differing. That is true once the +//! store itself is swapped out from under git (`git-ents-server`, phase 8, +//! `gix-receive` replacing `receive-pack` entirely because a Postgres +//! `RefStore` leaves no on-disk repo for stock git to act on). Phase 6 is +//! explicitly *not* that case: this deployment keeps a real on-disk repo +//! and lets git's own `receive-pack` perform the actual object unpack and +//! ref update — "stock git wearing the same gate everything else runs, not +//! a bespoke protocol" (`docs/development-plan.adoc`). +//! +//! Concretely: if `pre_receive` here called `ents_receive::receive` +//! (writing the ref through *our own* `LooseRefStore::transaction`) and +//! then exited zero, git's `receive-pack` would still go on to perform its +//! own internal ref update afterward, expecting the ref to still hold the +//! *old* value it read before the hook ran — but we would have already +//! moved it. That double-write is a real race, not a hypothetical one, so +//! this module deliberately does not use `receive`'s bundled write path +//! for this deployment shape. Instead: +//! +//! - [`pre_receive`] calls the *identical* [`ents_gate::verify`] every +//! other call site uses (`gate.call-sites`) for each proposed +//! transition, and lets git's native `pre-receive` whole-push-rejection +//! semantics implement `gate.mandatory-hosted` for free: refusing any +//! one transition (nonzero exit, reasons on stderr) aborts the entire +//! push before git writes anything, exactly what `Mode::Mandatory` +//! means. On a pass, this hook writes nothing itself — git's own +//! `receive-pack` performs the actual ref update once the hook exits +//! zero. +//! - [`post_receive`] runs after git has already updated every ref: it +//! opens a fresh [`crate::root::HostedRoot`] (whose `open` itself runs +//! the boot-time [`ents_receive::reconcile`] scan, +//! `receive.reconstructible`) and drains whatever is now outstanding, +//! running each via the Sprite executor and writing results back +//! through [`ents_effect::run::run_one`] — an ordinary `receive` client +//! for the *results* ref, which never conflicts with a branch ref git +//! itself just wrote. +//! +//! # Object visibility during `pre-receive` (quarantine) +//! +//! `receive.object-access`'s own doc flags "never a git hook's quarantine +//! directory, until its transaction commits" as the composition root's +//! responsibility. Git runs `pre-receive` with new objects visible only +//! through `GIT_OBJECT_DIRECTORY` (the quarantine) plus +//! `GIT_ALTERNATE_OBJECT_DIRECTORIES` (the real odb) until the push is +//! accepted; [`crate::root::HostedRoot::open`] honors +//! `GIT_OBJECT_DIRECTORY` when the environment sets it (which git does for +//! `pre-receive`, and does not for `post-receive`, whose objects are by +//! then no longer quarantined), and `gix_odb::at` follows the quarantine +//! directory's own `info/alternates` back to the real odb transparently. +//! +//! # No separate daemon +//! +//! There is deliberately no long-lived worker process in this phase: each +//! hook invocation is a fresh, short-lived process that reconciles fresh +//! from repository state (`receive.reconstructible`'s own guarantee) — +//! "push-triggered" (the deployment table's own word for hosted execution) +//! without any inter-process queue at all. The literal "in-memory +//! `EventSink`" the development plan names lives for exactly one hook +//! invocation's lifetime; nothing about `receive.reconstructible`'s +//! contract requires it to live longer, and the phase-6 exit criterion — +//! obligations regenerate correctly after a `kill -9` of the in-memory +//! queue — is exactly what happens between every pair of pushes, verified +//! directly in this crate's tests by dropping a `HostedRoot` and opening a +//! fresh one against the same on-disk state. + +#![expect( + clippy::let_underscore_must_use, + reason = "rejection reasons written to a hook's stderr are best-effort; a broken pipe here \ + is not actionable" +)] + +use std::io::{BufRead, Read, Write}; + +use ents_effect::run::run_one; +use ents_model::Effect; +use ents_query::Query; +use ents_receive::Mode; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind}; +use gix_ref_store::RefStoreRead; + +use crate::error::{Error, Result}; +use crate::root::HostedRoot; +use crate::sign::Signer; + +/// One proposed transition, as read from git's `pre-receive` stdin: one +/// `<old-oid> <new-oid> <refname>` line per ref in the push. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StdinTransition { + /// The refname being updated. + pub name: FullName, + /// The proposed new tip, or `None` for a deletion. + pub new: Option<ObjectId>, +} + +/// Parse git's `pre-receive`/`post-receive` stdin format: one +/// `<old> <new> <refname>` line per updated ref. +/// +/// # Errors +/// +/// [`Error::InvalidArgument`] for a line that does not have exactly three +/// whitespace-separated fields, an unparsable oid, or an invalid refname. +pub fn parse_stdin_transitions(input: impl BufRead) -> Result<Vec<StdinTransition>> { + let mut out = Vec::new(); + for line in input.lines() { + let line = line.map_err(|source| Error::Io { + path: "<stdin>".into(), + source, + })?; + let mut fields = line.split_whitespace(); + let (Some(_old), Some(new), Some(name)) = (fields.next(), fields.next(), fields.next()) + else { + return Err(Error::InvalidArgument(format!( + "malformed pre-receive line: {line:?}" + ))); + }; + let new: ObjectId = new + .parse() + .map_err(|_source| Error::InvalidArgument(format!("bad new oid: {new}")))?; + let name: FullName = name + .to_owned() + .try_into() + .map_err(|_source| Error::InvalidArgument(format!("bad refname: {name}")))?; + let new = (!new.is_null()).then_some(new); + out.push(StdinTransition { name, new }); + } + Ok(out) +} + +/// Run as git's own `pre-receive` hook (see this module's own doc for the +/// design). Reads transitions from `input`, evaluates the gate against +/// each, and refuses the whole push (returns `Err`) if any fails under the +/// mandatory gate. Rejection reasons are written to `report`. +/// +/// # Errors +/// +/// [`Error::Refused`] if any transition's verdict fails; propagates a +/// parse or gate-evaluation failure otherwise. +pub fn pre_receive(root: &HostedRoot, input: impl BufRead, mut report: impl Write) -> Result<()> { + let transitions = parse_stdin_transitions(input)?; + let mut failures = Vec::new(); + for transition in &transitions { + let verdict = ents_gate::verify( + &root.refs, + &root.objects, + &ents_gate::Update { + name: transition.name.clone(), + new: transition.new, + }, + )?; + if let ents_gate::Verdict::Fail(refusal) = verdict { + let _ = writeln!(report, "refused: {refusal}"); + failures.push(refusal.to_string()); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(Error::Refused(failures.join("; "))) + } +} + +/// Run as git's own `post-receive` hook: reconcile outstanding effect +/// obligations and run every one of them via `executor`, writing each +/// result back through the ordinary `receive` path +/// (`effect.results-writeback`). +/// +/// `root` must already have run its boot-time reconciliation scan (true of +/// any [`HostedRoot::open`]); this function additionally re-reconciles once +/// more before draining, so a push that itself just made new commits +/// outstanding is caught without waiting for the *next* process's boot. +/// +/// # Errors +/// +/// Propagates a reconciliation, toolchain-resolution, checkout, executor, +/// or write-back failure. A per-commit failure stops the drain at that +/// commit (mirrors [`ents_effect::run::run_effect`]'s own contract) — +/// results already written for earlier commits in this pass stay durable. +pub fn post_receive( + root: &HostedRoot, + executor: &dyn ents_effect::Executor, + scratch: &std::path::Path, + toolchain_cache: &std::path::Path, + signer: &Signer, +) -> Result<usize> { + ents_receive::reconcile(&root.refs, &root.objects, &root.events)?; + + let author = gix::actor::Signature { + name: "git-ents-hosted-worker".into(), + email: "worker@git.ents.cloud".into(), + time: gix::date::Time { + seconds: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or_default(), + offset: 0, + }, + }; + + let mut ran = 0usize; + for (effect_name, oid) in root.events.pending() { + let result_ref = ents_model::namespace::result_ref(&effect_name, &run_one_short(oid))?; + // Skip work already resulted: the sink may re-list an obligation + // whose result already landed in an earlier pass within the same + // process (`receive.dedup`'s spirit — idempotent re-delivery, + // never a duplicate effect run). + if root.refs.get(result_ref.as_ref())?.is_some() { + continue; + } + let Some(effect) = read_effect(&root.refs, &root.objects, &effect_name)? else { + continue; + }; + run_one( + &root.refs, + &root.objects, + &root.events, + executor, + scratch, + toolchain_cache, + oid, + &effect, + result_ref, + &author, + |payload| signer.sign(payload), + Mode::Mandatory, + )?; + ran = ran.saturating_add(1); + } + Ok(ran) +} + +/// The short-oid segment every results refname uses; mirrors +/// `ents_effect::run::short_oid` (private to that crate's own module path +/// from here, so this is a thin duplicate of a two-line slice operation +/// rather than a reason to change that crate's visibility). +fn run_one_short(oid: ObjectId) -> String { + let hex = oid.to_string(); + hex.get(..12).unwrap_or(&hex).to_owned() +} + +/// Read and parse the effect definition at `refs/meta/effects/<name>`, or +/// `None` if it is missing or malformed (mirrors `ents_receive::reconcile`'s +/// own tolerance for a pre-existing malformed effect). +fn read_effect(refs: &dyn RefStoreRead, objects: &impl Find, name: &str) -> Result<Option<Effect>> { + let effect_ref = ents_model::namespace::effect_ref(name)?; + let Some(tip) = refs.get(effect_ref.as_ref())? else { + return Ok(None); + }; + let mut buf = Vec::new(); + let Some(data) = objects + .try_find(&tip, &mut buf) + .map_err(|source| Error::InvalidArgument(source.to_string()))? + else { + return Ok(None); + }; + if data.kind != Kind::Commit { + return Ok(None); + } + let Ok(commit) = CommitRef::from_bytes(data.data, tip.kind()) else { + return Ok(None); + }; + let tree = commit.tree(); + let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, objects) else { + return Ok(None); + }; + // Confirm the trigger still parses, mirroring `reconcile`'s own + // tolerance rule; an effect whose trigger is unparsable is treated as + // "nothing to run" rather than a hard failure. + let _: Query = effect + .trigger + .parse() + .map_err(|_source| Error::InvalidArgument("unparsable trigger".to_owned()))?; + Ok(Some(effect)) +} + +/// A byte source the hook subcommands read stdin from — split out only so +/// tests can supply a fixed buffer instead of a real stdin handle. +pub fn read_all(mut input: impl Read) -> Result<Vec<u8>> { + let mut buf = Vec::new(); + input.read_to_end(&mut buf).map_err(|source| Error::Io { + path: "<stdin>".into(), + source, + })?; + Ok(buf) +}
crates/git-ents/src/lib.rs @@ -1,0 +1,85 @@ +//! `git-ents`: the local root, the CLI-complete milestone +//! (`docs/development-plan.adoc`, phase 6). +//! +//! This crate's one responsibility is composition and porcelain: it wires +//! the four seams every other crate defines a trait for — `RefStore` +//! ([`gix_ref_store`]), the object store (gitoxide's own `Find`/`Write`), +//! `EventSink` ([`ents_receive`]), and `Executor` ([`ents_effect`]) — into +//! two composition roots ([`root`]), and exposes a subcommand surface +//! above them. No business logic lives here that a library crate should +//! own instead: every command module is a thin caller of `ents-gate`, +//! `ents-receive`, `ents-effect`, `ents-anchor`, or `ents-sync`. +//! +//! # Spec coverage +//! +//! From `docs/spec/roots.adoc`: +//! +//! - `roots.composition`, `roots.local` — [`root::LocalRoot`]: the plain +//! CLI's composition root (loose-ref `RefStore`, the local odb, a null +//! `EventSink`, the advisory gate). +//! - `roots.config-isolation` — every trait implementation is selected in +//! [`root`] alone; no command module branches on configuration. +//! - `roots.worktree-update` — [`commands::setup`] sets +//! `receive.denyCurrentBranch=updateInstead` on the local repository, the +//! integration-test-harness edge case that spec section names. +//! +//! The development plan's phase-6 row additionally doubles this crate as +//! the single-node hosted root: [`root::HostedRoot`] and [`hook`] wire +//! loose refs and a real odb behind git's own `receive-pack`, an in-memory +//! `EventSink` reconciled at boot (`receive.reconstructible`), and a +//! `SpriteExecutor`. See those modules' own docs for the design this +//! deployment shape requires — the git-serving-transport case is +//! deliberately not `roots.hosted` (`git-ents-server`, phase 8, which +//! replaces the store itself); it is this same crate's wiring, reused, +//! per the plan's own framing. +//! +//! # Examples +//! +//! An end-to-end local write: enroll an admin member, then use it to +//! enroll a second member, mirroring what `git ents members add` does. +//! +//! ``` +//! use ents_model::{MemberId, Provenance}; +//! use ents_receive::Mode; +//! use git_ents::mutate::{Identity, outcome_to_result, propose_entity}; +//! use git_ents::root::LocalRoot; +//! use git_ents::sign::Signer; +//! use gix_ref_store::RefStoreRead; +//! +//! # let dir = tempfile::tempdir().expect("tempdir"); +//! # gix::init(dir.path()).expect("init"); +//! # let key_path = dir.path().join("id_ed25519"); +//! # { +//! # use ssh_key::private::{Ed25519Keypair, KeypairData}; +//! # let pair = Ed25519Keypair::from_seed(&[3; 32]); +//! # let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "t").expect("well-formed"); +//! # key.write_openssh_file(&key_path, ssh_key::LineEnding::LF).expect("write"); +//! # } +//! let root = LocalRoot::open(dir.path()).expect("opens"); +//! let signer = Signer::load(&key_path).expect("loads"); +//! let actor = gix::actor::Signature { +//! name: "jdc".into(), email: "jdc@ents.test".into(), +//! time: gix::date::Time { seconds: 1_000, offset: 0 }, +//! }; +//! let identity = Identity { actor, signer: &signer }; +//! +//! let member = ents_model::Member::new(signer.public_openssh(), Provenance::AdminRegistered); +//! let name = ents_model::namespace::member_ref(&MemberId::new("jdc")).expect("valid"); +//! let outcome = propose_entity( +//! &root.refs, &root.objects, &root.events, name.clone(), &member, +//! &identity, "Enroll jdc", root.mode(), +//! ).expect("evaluates"); +//! outcome_to_result(outcome, None).expect("bootstrap admits the first member"); +//! assert!(root.refs.get(name.as_ref()).expect("reads").is_some()); +//! ``` + +pub mod cli; +pub mod commands; +pub mod error; +pub mod exe; +pub mod hook; +pub mod mutate; +pub mod root; +pub mod sign; + +pub use error::{Error, Result};
crates/git-ents/src/main.rs @@ -1,0 +1,18 @@ +//! `git ents`: parse, hand to [`git_ents::exe`], map error to exit code. + +use std::process::ExitCode; + +use git_ents::cli::Cli; + +fn main() -> ExitCode { + let cli: Cli = figue::from_std_args().unwrap(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + match git_ents::exe::run(cli, &mut out) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +}
crates/git-ents/src/mutate.rs @@ -1,0 +1,181 @@ +//! One shared primitive every entity-mutation command uses: serialize a +//! typed tree, wrap it in a signed commit bound to its refname (per +//! `meta-ref.trailers`'s `Advance-ref` trailer), and hand it to +//! [`ents_receive::receive`] — the sole path a meta-ref mutation may enter +//! the repository (`receive.unit`). +//! +//! Every porcelain command that writes an entity (`members`, `account`, +//! `effect`, `toolchain`, `comment`, `redact`) goes through +//! [`propose_entity`] rather than repeating this shape, so there is +//! exactly one place that builds the trailer block, one place that signs, +//! and one place that calls `receive`. + +use ents_model::trailer::Trailers; +use ents_receive::{Mode, Outcome, Proposal, RefTransition, TxResult}; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::{Commit, Find, Kind, Write, WriteTo as _}; +use gix_ref_store::RefStore; + +use crate::error::{Error, Result}; +use crate::sign::Signer; + +/// Everything [`propose_entity`] needs about the acting identity: the +/// commit author/committer signature and the signer producing the +/// `gpgsig` header. +pub struct Identity<'a> { + /// The author and committer signature every mutation commit carries. + pub actor: gix::actor::Signature, + /// The loaded signing key. + pub signer: &'a Signer, +} + +/// Serialize `entity` into `objects`, wrap it in a commit bound to `name` +/// via the `Advance-ref` trailer, sign it with `identity`, and propose the +/// transition through [`ents_receive::receive`]. +/// +/// `name`'s current tip is read fresh from `refs` immediately before +/// building the commit, so the proposed transition's `old` is always +/// current — the CAS precondition `receive` (via `ents_gate::verify`) +/// checks is against this same read. +/// +/// # Errors +/// +/// [`Error::Tree`] if `entity` cannot be serialized; [`Error::Refs`] if +/// reading `name`'s current tip fails; [`Error::Receive`] if `receive` +/// itself could not reach an outcome. A reached-but-negative outcome +/// (refusal, staleness, redaction) is returned as `Ok` — callers translate +/// [`Outcome`] to a user-facing [`Error`] via [`outcome_to_result`]. +#[expect( + clippy::too_many_arguments, + reason = "one field per entity-mutation shape (refname, entity, identity, message, mode); \ + this is the crate's one shared primitive rather than one per caller" +)] +pub fn propose_entity<T: for<'facet> facet::Facet<'facet>>( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn ents_receive::EventSink, + name: FullName, + entity: &T, + identity: &Identity<'_>, + subject: &str, + mode: Mode, +) -> Result<Outcome> { + let tree = facet_git_tree::serialize_into(entity, objects)?; + let old = refs.get(name.as_ref())?; + + let trailers = Trailers { + ents_ref: Some(name.clone()), + schema_version: None, + }; + let message = format!("{subject}\n\n{}", trailers.render()); + + let mut commit = Commit { + tree, + parents: old.into_iter().collect::<Vec<_>>().into(), + author: identity.actor.clone(), + committer: identity.actor.clone(), + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + let mut payload = Vec::new(); + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \ + `ents_testutil::write_commit`'s identical, unguarded call" + )] + commit + .write_to(&mut payload) + .expect("serializing a commit to a Vec cannot fail"); + let pem = identity.signer.sign(&payload); + commit + .extra_headers + .push(("gpgsig".into(), pem.trim_end().into())); + + let mut raw = Vec::new(); + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \ + `ents_testutil::write_commit`'s identical, unguarded call" + )] + commit + .write_to(&mut raw) + .expect("serializing a commit to a Vec cannot fail"); + let tip = objects.write_buf(Kind::Commit, &raw)?; + + let proposal = Proposal { + transitions: vec![RefTransition { + name, + old, + new: Some(tip), + }], + objects: vec![tip], + auth: None, + }; + Ok(ents_receive::receive( + refs, objects, events, &proposal, mode, + )?) +} + +/// Delete the entity at `name` (a `new: None` transition) through +/// `receive`, the same shared path [`propose_entity`] uses for writes. +/// +/// # Errors +/// +/// See [`propose_entity`]. +pub fn propose_delete( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn ents_receive::EventSink, + name: FullName, + mode: Mode, +) -> Result<Outcome> { + let old = refs.get(name.as_ref())?; + let proposal = Proposal { + transitions: vec![RefTransition { + name, + old, + new: None, + }], + objects: vec![], + auth: None, + }; + Ok(ents_receive::receive( + refs, objects, events, &proposal, mode, + )?) +} + +/// Translate a reached [`Outcome`] into `Ok(tip)` on success or a +/// user-facing [`Error`] otherwise — the one place every command renders +/// `receive`'s result the same way. +/// +/// # Errors +/// +/// [`Error::Refused`] for a gate refusal (`gate.mandatory-hosted` +/// aborting on any failed verdict, or an advisory root's failed verdict a +/// caller chose to treat as fatal); [`Error::Stale`] for a compare-and-swap +/// rejection; [`Error::Redacted`] if a redacted object was refused. +pub fn outcome_to_result(outcome: Outcome, tip: Option<ObjectId>) -> Result<Option<ObjectId>> { + match outcome.result { + TxResult::Applied => Ok(tip), + TxResult::Refused => { + let reasons = outcome + .verdicts + .iter() + .filter_map(|(_, verdict)| match verdict { + ents_gate::Verdict::Fail(refusal) => Some(refusal.to_string()), + ents_gate::Verdict::Pass(_) => None, + }) + .collect::<Vec<_>>() + .join("; "); + Err(Error::Refused(reasons)) + } + TxResult::Rejected { name } => Err(Error::Stale { + name: name.as_bstr().to_string(), + }), + TxResult::Redacted { oid } => Err(Error::Redacted { oid }), + } +}
crates/git-ents/src/root.rs @@ -1,0 +1,424 @@ +//! Composition roots (`roots.composition`): the only place `git-ents` +//! wires the four seams — `RefStore`, the object store, `EventSink`, and +//! `Executor` — together. +//! +//! Two roots live in this module, per the development plan's phase-6 row: +//! +//! - [`LocalRoot`] (`roots.local`): the plain CLI, wired against whatever +//! repository the current directory is in — loose-ref `RefStore`, the +//! local odb, a null `EventSink`, the advisory gate. `Executor` is +//! chosen per invocation (`git ents effect run --executor`), never +//! fixed by this root, since local execution is pull-only +//! (`effect.local-run`) and never itself runs an effect as part of +//! composing the root. +//! - [`HostedRoot`] (the single-node hosted root the development plan's +//! `git-ents` row describes: "loose refs and a real odb on a Fly +//! volume, served behind git's own `receive-pack`... with an in-memory +//! `EventSink` and a boot-time reconciliation scan, and the Sprite +//! executor"): the same loose-ref/odb primitives as [`LocalRoot`], but +//! the mandatory gate, an in-memory `EventSink`, and a `SpriteExecutor` +//! — wired by the `git-ents hook` plumbing subcommands +//! ([`crate::hook`]) that git's own `receive-pack` invokes. +//! +//! Neither root is `roots.hosted` (`git-ents-server`, phase 8): that root +//! replaces the `RefStore` and object store with Postgres and Tigris and +//! is out of scope until scale forces it (`roots.honesty-test`). This +//! module's `HostedRoot` keeps git's own on-disk repository and +//! `receive-pack` as the transport, exactly as the development plan's +//! preamble describes for this phase. +//! +//! # Config isolation (`roots.config-isolation`) +//! +//! Every trait implementation is selected here, in these two structs, and +//! nowhere else: no command module reads an environment variable or git +//! config value to decide *which* `RefStore` or `Executor` to use — they +//! are only ever handed one already-constructed by a root. +//! +//! # Boundary rules this module upholds +//! +//! [`LocalRoot`] and [`HostedRoot`] are the first composition roots this +//! codebase has (every crate before phase 6 was a library, handed trait +//! objects rather than constructing them): `arch.store-composition-root` +//! ("a concrete store implementation... MUST be wired only inside a +//! composition root") and `arch.no-hosted-branch` ("a library crate MUST +//! NOT contain a branch on deployment mode") are both properties this +//! file demonstrates rather than merely states — `LocalRoot` and +//! `HostedRoot` are two distinct types, never one type with an +//! `if hosted` branch, and every command module ([`crate::commands`]) +//! takes an already-constructed root, never constructing a store itself. +// @relation(roots.composition, roots.config-isolation, arch.store-composition-root, arch.no-hosted-branch, scope=file) + +use std::path::{Path, PathBuf}; + +use ents_receive::{Mode, NullEventSink}; +use gix_ref_store::LooseRefStore; + +use crate::error::{Error, Result}; + +/// A real, on-disk object store: the repository's own odb, opened for +/// genuine reads *and* writes (`arch.no-object-store-trait`: accessed only +/// through gitoxide's own `Find`/`Write` traits, never a private one). +/// +/// `gix::Repository::objects` proxies writes into an in-memory overlay by +/// default (so in-process object creation can be staged before a +/// transaction commits); a composition root that wants every write to land +/// on disk immediately calls +/// [`gix_odb::memory::Proxy::with_write_passthrough`] to strip that +/// overlay off, which is exactly what [`open_objects`] does. This is the +/// "which object directory... is the composition root's responsibility to +/// wire" `ents_receive::receive` itself defers to its caller. +pub type Objects = gix::OdbHandle; + +/// Open `path`'s repository and return a real, write-through object store +/// over it (see [`Objects`]'s own doc for why `with_write_passthrough` is +/// required here). +/// +/// # Errors +/// +/// [`Error::Repo`] if `path` is not a git repository `gix` can open. +pub fn open_objects(path: &Path) -> Result<Objects> { + let repo = gix::open(path)?; + Ok(repo.objects.with_write_passthrough()) +} + +/// The local composition root (`roots.local`): a loose-ref `RefStore`, the +/// local odb, a null `EventSink`, the advisory gate. Wired once per CLI +/// invocation against whichever repository the current directory +/// discovers. +/// +/// # Examples +/// +/// ``` +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # gix::init(dir.path()).expect("init"); +/// use git_ents::root::LocalRoot; +/// +/// let root = LocalRoot::open(dir.path()).expect("opens a real repository"); +/// assert_eq!(root.mode(), ents_receive::Mode::Advisory); +/// ``` +pub struct LocalRoot { + /// The repository path this root was opened against. + pub path: PathBuf, + /// The loose-ref `RefStore` (`arch.loose-cas-discipline`). + pub refs: LooseRefStore, + /// The real, on-disk object store. + pub objects: Objects, + /// The null `EventSink` (`roots.local`): local effect execution is + /// pull-only, so nothing is ever enqueued here (`effect.local-run`). + pub events: NullEventSink, +} + +impl LocalRoot { + /// Open the local composition root against the repository at `path`. + /// + /// # Errors + /// + /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git + /// repository, or its refs cannot be opened. + pub fn open(path: impl AsRef<Path>) -> Result<Self> { + let path = path.as_ref().to_owned(); + let refs = LooseRefStore::open(&path)?; + let objects = open_objects(&path)?; + Ok(Self { + path, + refs, + objects, + events: NullEventSink, + }) + } + + /// Discover the repository from `start` upward (mirroring `git`'s own + /// discovery), then open the local root against it. + /// + /// # Errors + /// + /// [`Error::NotARepo`] if no git repository is found at or above + /// `start`. + pub fn discover(start: impl AsRef<Path>) -> Result<Self> { + let start = start.as_ref(); + let discovered = gix::discover(start).map_err(|_source| Error::NotARepo { + path: start.to_owned(), + })?; + let path = discovered.workdir().unwrap_or_else(|| discovered.path()); + Self::open(path) + } + + /// The gate policy this root runs under: always advisory + /// (`gate.advisory-local`) — a local write is annotated, never + /// blocked. + #[must_use] + pub fn mode(&self) -> Mode { + Mode::Advisory + } +} + +/// The single-node hosted composition root: the same loose-ref/odb +/// primitives [`LocalRoot`] uses, but the mandatory gate +/// (`gate.mandatory-hosted`) — a push landing on the actual canonical +/// remote has teeth (`docs/design.adoc`: "the hosted server is not where +/// policy lives — it is the one place where the verdict has teeth") — and +/// an in-memory `EventSink` reconciled at boot +/// (`receive.reconstructible`). +/// +/// This is wired by [`crate::hook`]'s plumbing subcommands, which git's own +/// `receive-pack` invokes as `pre-receive`/`post-receive` hooks; see that +/// module's doc for why the ref *write* itself is left to git's native +/// `receive-pack` rather than `ents_receive::receive`'s own +/// `RefStore::transaction` in this deployment shape. +pub struct HostedRoot { + /// The repository path this root was opened against. + pub path: PathBuf, + /// The loose-ref `RefStore`. + pub refs: LooseRefStore, + /// The real, on-disk object store, transparently extended to also read + /// through a `pre-receive` quarantine directory when the environment + /// names one (`GIT_OBJECT_DIRECTORY`) — see [`QuarantineObjects`]'s own + /// doc for why this is an in-process read chain rather than a written + /// `info/alternates` file. + pub objects: QuarantineObjects, + /// The in-memory `EventSink`, reconciled at boot + /// (`receive.reconstructible`). + pub events: ents_receive::MemoryEventSink, +} + +impl HostedRoot { + /// Open the hosted composition root against the repository at `path`, + /// honoring a pre-receive quarantine object directory if the + /// environment names one (`GIT_OBJECT_DIRECTORY`), and immediately run + /// the boot-time reconciliation scan (`receive.reconstructible`) to + /// populate the in-memory `EventSink` from repository state alone. + /// + /// # Errors + /// + /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git + /// repository; [`Error::Receive`] if the reconciliation scan itself + /// fails to read repository state. + pub fn open(path: impl AsRef<Path>) -> Result<Self> { + let path = path.as_ref().to_owned(); + let refs = LooseRefStore::open(&path)?; + let objects = QuarantineObjects::open(&path)?; + let events = ents_receive::MemoryEventSink::default(); + ents_receive::reconcile(&refs, &objects, &events)?; + Ok(Self { + path, + refs, + objects, + events, + }) + } + + /// The gate policy this root runs under: always mandatory + /// (`gate.mandatory-hosted`) — the canonical hosted remote's writes + /// are actually enforced, not merely annotated. + #[must_use] + pub fn mode(&self) -> Mode { + Mode::Mandatory + } +} + +/// The real, on-disk object store, transparently extended to also read +/// through a `pre-receive` quarantine directory when the environment names +/// one (`GIT_OBJECT_DIRECTORY`). +/// +/// # Why an in-process read chain, not a written `info/alternates` file +/// +/// git's own `pre-receive` quarantine does *not* write a physical +/// `info/alternates` file into the quarantine directory it hands hook +/// processes — it communicates the real odb's location purely via the +/// `GIT_ALTERNATE_OBJECT_DIRECTORIES` environment variable (confirmed +/// empirically: a quarantine directory git creates has no +/// `info/alternates` at all). `gix_odb::at`, in contrast, only ever +/// follows a physical alternates *file* — it does not consult this +/// environment variable itself. This is exactly the "which object +/// directory... is the composition root's responsibility to wire" gap +/// `ents_receive::receive`'s own doc names for the quarantine case. +/// +/// The first attempt at closing this gap wrote the environment's paths +/// into the quarantine's own `info/alternates` file, reasoning that the +/// quarantine directory is discarded once the push resolves. That +/// reasoning was wrong: git's quarantine finalization *moves* the +/// quarantine directory's entire contents — including any file a hook +/// process wrote into it — onto the real object directory once the push +/// is accepted, permanently persisting a written alternates file at +/// `objects/info/alternates` whose own content names `objects` itself, a +/// self-cycle that fails every future open of the repository (observed +/// directly: a second push into the same repository failed with +/// `gix_odb`'s own "Alternates form a cycle" error, `objects` pointing at +/// itself). Nothing here writes to the object directory at all now, +/// closing that hazard structurally rather than by adding another +/// disk-state special case. +/// +/// # Examples +/// +/// ``` +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # gix::init_bare(dir.path()).expect("init"); // HostedRoot always runs against a bare repo. +/// use git_ents::root::QuarantineObjects; +/// +/// // No `GIT_OBJECT_DIRECTORY` set: reads go straight to the real odb. +/// let objects = QuarantineObjects::open(dir.path()).expect("opens"); +/// let missing = gix_hash::ObjectId::null(gix_hash::Kind::Sha1); +/// assert!( +/// gix_object::Find::try_find(&objects, &missing, &mut Vec::new()) +/// .expect("a missing lookup is Ok(None), not an error") +/// .is_none() +/// ); +/// ``` +pub struct QuarantineObjects { + /// The quarantine directory's own odb, when `GIT_OBJECT_DIRECTORY` + /// names one distinct from the repository's real `objects/` — + /// consulted first, so a push's own not-yet-committed objects are + /// visible before the fallback. + quarantine: Option<gix_odb::Handle>, + /// The repository's real, on-disk odb — reads fall back to this, and + /// every write always lands here (see [`gix_object::Write`]'s impl): + /// `pre-receive` never writes objects itself + /// (`ents_gate::verify` is read-only), so this is only exercised by + /// `post-receive`'s write-back path, which never runs under a + /// quarantine at all. + real: gix_odb::Handle, +} + +impl QuarantineObjects { + /// Open the object store for the repository at `path`, chaining a + /// `pre-receive` quarantine directory in front of the real odb when + /// the environment names one distinct from `path`'s own `objects/` + /// (canonicalized, since a quarantine and the real directory can + /// otherwise compare unequal only by an unresolved symlink or a `/./` + /// path component). + /// + /// # Errors + /// + /// [`Error::Io`] if either directory cannot be opened as an object + /// store. + pub fn open(path: &Path) -> Result<Self> { + let real_dir = path.join("objects"); + let real = gix_odb::at(&real_dir).map_err(|source| Error::Io { + path: real_dir.clone(), + source, + })?; + let quarantine = match std::env::var_os("GIT_OBJECT_DIRECTORY") { + Some(dir) => { + let dir = PathBuf::from(dir); + let is_real_quarantine = match (dir.canonicalize(), real_dir.canonicalize()) { + (Ok(q), Ok(r)) => q != r, + _ => dir != real_dir, + }; + if is_real_quarantine { + Some(gix_odb::at(&dir).map_err(|source| Error::Io { path: dir, source })?) + } else { + None + } + } + None => None, + }; + Ok(Self { quarantine, real }) + } +} + +// @relation(arch.no-object-store-trait, scope=function) +impl gix_object::Find for QuarantineObjects { + fn try_find<'a>( + &self, + id: &gix_hash::oid, + buffer: &'a mut Vec<u8>, + ) -> std::result::Result<Option<gix_object::Data<'a>>, gix_object::find::Error> { + // The quarantine attempt reads into its own, function-local + // buffer rather than the caller's `buffer` (whose lifetime `'a` + // is named, not elided, so the borrow checker cannot shrink a + // second, conditional reborrow of it to a sub-region even though + // only one branch ever executes at runtime) — copying the found + // bytes into `buffer` afterward keeps this the same one-call-site + // shape `gix_odb::memory::Proxy::try_find` uses for its own + // primary-then-fallback lookup. + if let Some(quarantine) = &self.quarantine { + let mut local = Vec::new(); + if let Some(found) = quarantine.try_find(id, &mut local)? { + let kind = found.kind; + buffer.clear(); + buffer.extend_from_slice(found.data); + return Ok(Some(gix_object::Data { + kind, + object_hash: id.kind(), + data: buffer.as_slice(), + })); + } + } + self.real.try_find(id, buffer) + } +} + +impl gix_object::Write for QuarantineObjects { + fn write_stream( + &self, + kind: gix_object::Kind, + size: u64, + from: &mut dyn std::io::Read, + ) -> std::result::Result<gix_hash::ObjectId, gix_object::write::Error> { + self.real.write_stream(kind, size, from) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + /// `arch.no-hosted-branch`: the two roots are distinct types with a + /// fixed, compile-time-chosen gate policy each — never one type + /// branching on a runtime "am I hosted?" check. Table-driven because + /// the spec enumerates exactly these two cases, one per root. + #[rstest] + #[case::local_is_advisory(true, Mode::Advisory)] + #[case::hosted_is_mandatory(false, Mode::Mandatory)] + // @relation(arch.no-hosted-branch, roots.config-isolation, scope=function, role=Verifies) + fn each_root_has_one_fixed_mode(#[case] local: bool, #[case] expected: Mode) { + let dir = tempfile::tempdir().expect("tempdir"); + let mode = if local { + gix::init(dir.path()).expect("init"); + LocalRoot::open(dir.path()).expect("opens").mode() + } else { + gix::init_bare(dir.path()).expect("init bare"); + HostedRoot::open(dir.path()).expect("opens").mode() + }; + assert_eq!(mode, expected); + } + + /// `arch.store-composition-root`: opening either root is the *only* + /// place a `LooseRefStore`/odb pair gets constructed — every command + /// module ([`crate::commands`]) only ever receives an already-built + /// root, never builds one of its own store handles. + #[rstest] + // @relation(arch.store-composition-root, scope=function, role=Verifies) + fn opening_a_root_is_the_only_construction_path() { + let dir = tempfile::tempdir().expect("tempdir"); + gix::init(dir.path()).expect("init"); + let root = LocalRoot::open(dir.path()).expect("opens"); + // The root itself is the seam every command module is handed; + // there is no second, parallel way to obtain a `RefStore`/odb + // pair for this repository within this crate. + assert_eq!(root.path, dir.path()); + } + + /// `arch.no-object-store-trait`: `QuarantineObjects` reads and writes + /// exclusively through gitoxide's own `Find`/`Write` traits — no + /// private object-store trait exists in this crate for it to + /// implement instead. + #[rstest] + // @relation(arch.no-object-store-trait, scope=function, role=Verifies) + fn quarantine_objects_round_trips_through_gitoxides_own_traits() { + let dir = tempfile::tempdir().expect("tempdir"); + gix::init_bare(dir.path()).expect("init bare"); + let objects = QuarantineObjects::open(dir.path()).expect("opens"); + + let oid = gix_object::Write::write(&objects, &gix_object::Tree::empty()).expect("writes"); + let mut buf = Vec::new(); + let found = gix_object::Find::try_find(&objects, &oid, &mut buf) + .expect("reads") + .expect("just written"); + assert_eq!(found.kind, gix_object::Kind::Tree); + } +}
crates/git-ents/src/sign.rs @@ -1,0 +1,147 @@ +//! Real SSH commit signing: the production counterpart to +//! `ents_testutil::Keypair` — the same SSHSIG-over-`git`-namespace shape +//! `ents_gate::signature` verifies, but loaded from the user's own key +//! instead of a deterministic test seed. +//! +//! This is new work, not a port: `pre-redo`'s CLI shelled out to `git +//! commit -S`/`git push --signed` and let stock git invoke +//! `ssh-keygen -Y sign` itself. The redone architecture's mutation +//! frontends build and sign the commit object themselves, in-process, +//! before handing it to [`ents_receive::receive`] (`receive.unit`), so +//! `git-ents` needs its own signer rather than a subprocess shelling to +//! `git`. + +use std::path::{Path, PathBuf}; + +use ssh_key::{HashAlg, LineEnding, PrivateKey}; + +use crate::error::{Error, Result}; + +/// The SSHSIG namespace git signs commits under — mirrors +/// `ents_testutil::keys::GIT_SIGN_NAMESPACE` and +/// `ents_gate::signature`'s verification side. +const GIT_SIGN_NAMESPACE: &str = "git"; + +/// A loaded SSH signing identity: the private key material plus its +/// OpenSSH public-key line, exactly the string an +/// [`ents_model::Member::key`] carries. +/// +/// # Examples +/// +/// ``` +/// # use ssh_key::private::{Ed25519Keypair, KeypairData}; +/// # let dir = tempfile::tempdir().expect("tempdir"); +/// # let path = dir.path().join("id_ed25519"); +/// # let pair = Ed25519Keypair::from_seed(&[7; 32]); +/// # let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed"); +/// # key.write_openssh_file(&path, ssh_key::LineEnding::LF).expect("write"); +/// use git_ents::sign::Signer; +/// +/// let signer = Signer::load(&path).expect("loads"); +/// assert!(signer.public_openssh().starts_with("ssh-ed25519 ")); +/// +/// let pem = signer.sign(b"payload"); +/// assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----")); +/// ``` +#[derive(Debug)] +pub struct Signer { + private: PrivateKey, +} + +impl Signer { + /// Load a signing identity from an OpenSSH private key file at `path`. + /// + /// # Errors + /// + /// [`Error::BadSigningKey`] if `path` cannot be read, is not a + /// well-formed OpenSSH private key, or is passphrase-protected — an + /// encrypted key is a deliberate deferral (see this module's own + /// doc): this phase supports only an unencrypted key file. + pub fn load(path: &Path) -> Result<Self> { + let private = + PrivateKey::read_openssh_file(path).map_err(|source| Error::BadSigningKey { + path: path.to_owned(), + detail: source.to_string(), + })?; + if private.is_encrypted() { + return Err(Error::BadSigningKey { + path: path.to_owned(), + detail: "passphrase-protected keys are not supported yet; use an unencrypted key \ + or ssh-agent (deferred)" + .to_owned(), + }); + } + Ok(Self { private }) + } + + /// The public half in OpenSSH single-line format — what a + /// [`ents_model::Member`]'s `key` field stores. + #[must_use] + pub fn public_openssh(&self) -> String { + #[expect( + clippy::expect_used, + reason = "rendering an already-loaded key's own public half cannot fail; mirrors \ + `ents_testutil::Keypair::public_openssh`'s identical, unguarded call" + )] + self.private + .public_key() + .to_openssh() + .expect("a loaded key's public half always renders") + } + + /// Sign `payload` in git's SSHSIG namespace, returning the armored PEM + /// block git stores in a commit's `gpgsig` header — identical shape to + /// `ents_testutil::Keypair::sign`. + /// + /// # Panics + /// + /// Never for a well-formed loaded key; signing an arbitrary byte + /// payload cannot fail for the algorithms this module accepts. + #[must_use] + pub fn sign(&self, payload: &[u8]) -> String { + #[expect( + clippy::expect_used, + reason = "signing and PEM-rendering an ed25519 signature over any byte payload is \ + infallible; mirrors `ents_testutil::Keypair::sign`'s identical, unguarded call" + )] + self.private + .sign(GIT_SIGN_NAMESPACE, HashAlg::Sha512, payload) + .expect("signing is infallible for a loaded, unencrypted key") + .to_pem(LineEnding::LF) + .expect("an SSHSIG always renders as PEM") + } +} + +/// Resolve the signing key path a command should use: `--key` if given, +/// else the repository's (or global) `user.signingkey`, else the default +/// `~/.ssh/id_ed25519`. +/// +/// # Errors +/// +/// [`Error::NoSigningKey`] when none of the three sources resolves to a +/// path. +pub fn resolve_key_path(repo: &gix::Repository, explicit: Option<&Path>) -> Result<PathBuf> { + if let Some(path) = explicit { + return Ok(path.to_owned()); + } + if let Some(configured) = repo + .config_snapshot() + .string("user.signingkey") + .map(|v| v.to_string()) + { + return Ok(PathBuf::from(configured)); + } + if let Some(home) = home_dir() { + let default = home.join(".ssh").join("id_ed25519"); + if default.exists() { + return Ok(default); + } + } + Err(Error::NoSigningKey) +} + +/// The current user's home directory, however the platform exposes it — +/// `$HOME` on every platform `git-ents` targets. +fn home_dir() -> Option<PathBuf> { + std::env::var_os("HOME").map(PathBuf::from) +}