diff --git a/Cargo.lock b/Cargo.lock index ceaa402..ba4f7df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -127,6 +133,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-trait" version = "0.1.91" @@ -211,6 +232,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -235,18 +277,41 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bytesize" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" + [[package]] name = "cc" version = "1.3.0" @@ -335,6 +400,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -380,6 +454,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -436,12 +534,57 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "digest" version = "0.10.7" @@ -463,6 +606,12 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ed25519" version = "3.0.0" @@ -485,6 +634,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -501,6 +659,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -513,12 +681,34 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -544,110 +734,1056 @@ dependencies = [ ] [[package]] -name = "futures-channel" -version = "0.3.33" +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-transport", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "parking_lot", + "regex", + "signal-hook", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6760ef6ef4edfa6449918977d59518ff209676f47e89bdce87a36a50be35e834" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06d20ff0e88ada2dd852b3852727b664ec9baefdf653d1d4e722e858c52cd17" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f813e312a3f7f327187823cd4c754a3e4d948c98907d11e8f37554a2b6b8059" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-credentials" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b637f873d5f9aa67e672c1b23741a18e046d33028017adede32f4b4087a2e7" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-mailmap" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-negotiate" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-zlib", + "memmap2", + "parking_lot", + "smallvec", + "thiserror", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed3e8d7a82e886e17a72e03d4ba0c13db6f2219b6cd4e2900b4cae426ec20c9" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-pathspec" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror", +] + +[[package]] +name = "gix-prompt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-credentials", + "gix-date", + "gix-features", + "gix-hash", + "gix-lock", + "gix-negotiate", + "gix-object", + "gix-ref", + "gix-refspec", + "gix-revwalk", + "gix-shallow", + "gix-trace", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-status" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", + "windows-sys", +] + +[[package]] +name = "gix-submodule" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c1bcf30081eb8ab04540a5795c67a2fcb22cb2e976e8b1a4ea657b1ed61469" dependencies = [ - "futures-core", - "futures-sink", + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", ] [[package]] -name = "futures-core" -version = "0.3.33" +name = "gix-traverse" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] [[package]] -name = "futures-executor" -version = "0.3.33" +name = "gix-url" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "42d10e53b8eae21ee601687f47bbbd6cb2ed7162cb4c1cafdd422fb7ec64cbee" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror", ] [[package]] -name = "futures-io" -version = "0.3.33" +name = "gix-utils" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +dependencies = [ + "bstr", + "fastrand", + "getrandom 0.4.3", + "unicode-normalization", +] [[package]] -name = "futures-macro" -version = "0.3.33" +name = "gix-validate" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "bstr", ] [[package]] -name = "futures-sink" -version = "0.3.33" +name = "gix-worktree" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +dependencies = [ + "bstr", + "gix-attributes", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] [[package]] -name = "futures-task" -version = "0.3.33" +name = "gix-worktree-state" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror", +] [[package]] -name = "futures-util" -version = "0.3.33" +name = "gix-worktree-stream" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "gix-zlib" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" dependencies = [ - "typenum", - "version_check", + "thiserror", + "zlib-rs", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "byteorder", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -655,6 +1791,21 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -707,6 +1858,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -782,7 +1939,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -797,6 +1964,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -830,6 +2050,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -848,6 +2077,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -883,6 +2121,7 @@ name = "mt-core" version = "0.1.8" dependencies = [ "chrono", + "gix", "serde", "serde_json", "sha2", @@ -906,7 +2145,7 @@ version = "2.16.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ctor", "napi-derive", "napi-sys", @@ -958,6 +2197,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-traits" version = "0.2.19" @@ -979,6 +2224,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -991,6 +2259,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1009,6 +2292,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + [[package]] name = "quote" version = "1.0.47" @@ -1076,6 +2370,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "regex" version = "1.13.1" @@ -1120,7 +2423,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1139,6 +2442,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1234,6 +2552,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1245,12 +2573,28 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1289,6 +2633,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1362,6 +2712,21 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1499,12 +2864,36 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -1535,6 +2924,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1595,6 +2994,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1695,6 +3125,12 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 4fca81c..883771e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ tokio-tungstenite = "0.30" futures = "0.3" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" +gix = { version = "0.86", features = ["blocking-network-client"] } diff --git a/crates/agent-server/Cargo.toml b/crates/agent-server/Cargo.toml index afbb5e3..0e780aa 100644 --- a/crates/agent-server/Cargo.toml +++ b/crates/agent-server/Cargo.toml @@ -25,4 +25,5 @@ tokio-tungstenite.workspace = true uuid = { workspace = true, features = ["v4"] } [dev-dependencies] +mt-core = { path = "../mt-core", features = ["test-support"] } tempfile.workspace = true diff --git a/crates/agent-server/src/graph.rs b/crates/agent-server/src/graph.rs index d352fe1..6b69c21 100644 --- a/crates/agent-server/src/graph.rs +++ b/crates/agent-server/src/graph.rs @@ -10,13 +10,13 @@ //! git.md: у `main` він не потрапляє ніколи. use std::path::{Path, PathBuf}; -use std::process::Command; use chrono::{Duration, Utc}; use mt_core::claims::{ acquire_claim, discover_repo_root, node_hash, release_claim, renew_or_takeover_claim, tasks_root_relative, ClaimFields, RUN_REF_PREFIX, }; +use mt_core::git::{GitRepository, SignaturePolicy}; use mt_core::publish::{fenced_publish, PublishOutcome, PublishRequest}; use mt_core::worktree::{create_run_worktree, push_run_ref, remove_run_worktree}; use serde::{Deserialize, Serialize}; @@ -66,21 +66,12 @@ pub struct InteractiveRun { approvals: Vec, } -fn git(dir: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +fn commit_all(worktree: &Path, message: &str) -> Result, String> { + GitRepository::open(worktree) + .and_then(|repository| { + repository.commit_all_if_changed(message, SignaturePolicy::Configured) + }) + .map_err(|error| error.to_string()) } fn iso(ts: chrono::DateTime) -> String { @@ -112,24 +103,26 @@ fn attach_impl( let tasks_root_rel = tasks_root_relative(&repo_root, &config.tasks_dir)?; let hash = node_hash(&tasks_root_rel, node); - git(&repo_root, &["fetch", "--quiet", "origin", "main"])?; - let base_sha = git(&repo_root, &["rev-parse", "origin/main"])?; + let repository = GitRepository::open(&repo_root).map_err(|error| error.to_string())?; + repository + .fetch_refspec("+refs/heads/main:refs/remotes/origin/main") + .map_err(|error| error.to_string())?; + let base_sha = repository + .resolve_ref("refs/remotes/origin/main") + .map_err(|error| error.to_string())?; let worktree_base = match resume_token { None => base_sha.clone(), Some(old_token) => { let old_run_ref = format!("{RUN_REF_PREFIX}/{hash}/{old_token}"); - git( - &repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{old_run_ref}:{old_run_ref}"), - ], - ) - .map_err(|e| format!("attach-resume: старий run ref {old_run_ref} недоступний: {e}"))?; - git(&repo_root, &["rev-parse", &old_run_ref])? + repository + .fetch_refspec(&format!("+{old_run_ref}:{old_run_ref}")) + .map_err(|error| { + format!("attach-resume: старий run ref {old_run_ref} недоступний: {error}") + })?; + repository.resolve_ref(&old_run_ref).map_err(|error| { + format!("attach-resume: старий run ref {old_run_ref} недоступний: {error}") + })? } }; @@ -215,12 +208,9 @@ impl InteractiveRun { std::fs::write(nitra_dir.join("session.jsonl"), session_jsonl) .map_err(|e| e.to_string())?; - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if staged.is_empty() { + if commit_all(&self.worktree, message)?.is_none() { return Ok(()); } - git(&self.worktree, &["commit", "-q", "-m", message])?; push_run_ref(&self.worktree, &self.node_hash, &self.token) } @@ -288,29 +278,23 @@ impl InteractiveRun { // Remote run ref стоїть на останньому запушеному ході (HEAD ДО // артефакт/strip-комітів) — саме його очікує force-with-lease. - let run_ref_sha = git(&self.worktree, &["rev-parse", "HEAD"])?; + let run_ref_sha = GitRepository::open(&self.worktree) + .and_then(|repository| repository.resolve_ref("HEAD")) + .map_err(|error| error.to_string())?; self.write_run_artifacts()?; - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if !staged.is_empty() { - git( - &self.worktree, - &[ - "commit", - "-q", - "-m", - &format!("mt: {} run (success)", self.node), - ], - )?; - } - let tracked = git(&self.worktree, &["ls-files", ".nitra"])?; - if !tracked.is_empty() { - git(&self.worktree, &["rm", "-r", "-q", "--cached", ".nitra"])?; - git( - &self.worktree, - &["commit", "-q", "-m", "mt: strip session artifacts"], - )?; + commit_all(&self.worktree, &format!("mt: {} run (success)", self.node))?; + let repository = GitRepository::open(&self.worktree).map_err(|error| error.to_string())?; + if repository + .remove_from_index(".nitra") + .map_err(|error| error.to_string())? + { + repository + .commit_staged_if_changed( + "mt: strip session artifacts", + SignaturePolicy::Configured, + ) + .map_err(|error| error.to_string())?; } let request = PublishRequest { worktree: &self.worktree, @@ -347,14 +331,7 @@ impl InteractiveRun { let nnn = mt_core::nnn::pad_nnn(mt_core::signal::next_run_nnn(&dir)); mt_core::signal::write_run_fm(&dir, &nnn, &self.actor, "handoff", "\n", "")?; - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if !staged.is_empty() { - git( - &self.worktree, - &["commit", "-q", "-m", &format!("mt: {} handoff", self.node)], - )?; - } + commit_all(&self.worktree, &format!("mt: {} handoff", self.node))?; push_run_ref(&self.worktree, &self.node_hash, &self.token)?; let ticket = HandoffTicket { @@ -370,6 +347,7 @@ impl InteractiveRun { #[cfg(test)] mod tests { use super::*; + use mt_core::test_support::{blob, commit_all, push_head, remote_refs, TestRepo}; /// Герметична фікстура: bare-репо як origin + робочий клон із tasks- /// директорією `mt/demo` на `main` (патерн mt-core test_support). @@ -379,39 +357,13 @@ mod tests { work: tempfile::TempDir, } - fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - impl Fixture { fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); + let TestRepo { origin, work } = TestRepo::new(); std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); + commit_all(work.path(), "add task"); + push_head(work.path(), "refs/heads/main"); Self { origin, work } } @@ -420,7 +372,7 @@ mod tests { } fn remote_refs(&self) -> String { - super::git(self.work.path(), &["ls-remote", "origin"]).unwrap() + remote_refs(self.work.path()).join("\n") } } @@ -440,18 +392,15 @@ mod tests { refs.contains(&format!("refs/mt/runs/{}/{}", run.node_hash, run.token)), "{refs}" ); - let head = super::git(&run.worktree, &["rev-parse", "HEAD"]).unwrap(); + let head = mt_core::test_support::head(&run.worktree); assert_eq!(head, run.base_sha, "worktree від base_sha (origin/main)"); // Claim позначений інтерактивним (0.3.0, ADR 260711-2100). - let claim_yaml = super::git( - Path::new(fixture.origin.path()), - &[ - "show", - &format!("refs/mt/claims/{}:.mt-claim.yml", run.node_hash), - ], - ) - .unwrap(); + let claim_yaml = blob( + fixture.origin.path(), + &format!("refs/mt/claims/{}", run.node_hash), + ".mt-claim.yml", + ); assert!(claim_yaml.contains("interactive: true"), "{claim_yaml}"); } @@ -478,35 +427,25 @@ mod tests { // Журнал доїхав у run ref. let run_ref = format!("refs/mt/runs/{}/{}", run.node_hash, run.token); - let journal = super::git( - fixture.work.path(), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ); - // ls-remote бачить ref, а show читає локальний — worktree пушить - // напряму в origin; читаємо з origin. - let origin_journal = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ) - .unwrap(); - assert_eq!(origin_journal, "{\"seq\":0}"); - drop(journal); + let origin_journal = blob(fixture.origin.path(), &run_ref, ".nitra/session.jsonl"); + assert_eq!(origin_journal, "{\"seq\":0}\n"); let node_hash = run.node_hash.clone(); let outcome = run.done(3, 10).unwrap(); assert!(outcome.published, "{outcome:?}"); // main просунувся, fact є, .nitra/ немає, claim/run ref прибрані. - let main_files = super::git( - Path::new(fixture.origin.path()), - &["ls-tree", "-r", "--name-only", "main"], + assert!(blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/fact_001.md" ) - .unwrap(); - assert!(main_files.contains("mt/demo/fact_001.md"), "{main_files}"); - assert!( - !main_files.contains(".nitra"), - ".nitra/ не мусить потрапити у main: {main_files}" - ); + .contains("ok")); + let main = mt_core::git::GitRepository::open(fixture.origin.path()).unwrap(); + let main_sha = main.resolve_ref("refs/heads/main").unwrap(); + assert!(main + .read_blob_at_commit(&main_sha, ".nitra/session.jsonl") + .is_err()); let refs = fixture.remote_refs(); assert!( !refs.contains(&format!("refs/mt/claims/{node_hash}")), @@ -526,9 +465,8 @@ mod tests { "## Task\n\n## Check\n\ntest -f ready\n", ) .unwrap(); - sh(fixture.work.path(), &["add", "."]); - sh(fixture.work.path(), &["commit", "-q", "-m", "check"]); - sh(fixture.work.path(), &["push", "-q", "origin", "main"]); + commit_all(fixture.work.path(), "check"); + push_head(fixture.work.path(), "refs/heads/main"); let run = attach(&fixture.config(), "demo").unwrap(); run.commit_turn("{}\n", "mt: хід").unwrap(); @@ -561,22 +499,22 @@ mod tests { let outcome = run.done(3, 10).unwrap(); assert!(outcome.published, "{outcome:?}"); - let run_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/run_001.md"], - ) - .unwrap(); + let run_md = blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/run_001.md", + ); assert!(run_md.starts_with("---\nschema_version: 1"), "{run_md}"); assert!(run_md.contains("actor: human"), "{run_md}"); assert!(run_md.contains("result: success"), "{run_md}"); assert!(run_md.contains("## Approvals"), "{run_md}"); assert!(run_md.contains("request=req-1"), "{run_md}"); - let fact_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/fact_001.md"], - ) - .unwrap(); + let fact_md = blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/fact_001.md", + ); assert!(fact_md.contains("## Summary"), "{fact_md}"); } @@ -594,11 +532,11 @@ mod tests { assert!(run.done(3, 10).unwrap().published); - let fact_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/fact_001.md"], - ) - .unwrap(); + let fact_md = blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/fact_001.md", + ); assert!(fact_md.contains("власний fact виконавця"), "{fact_md}"); } @@ -664,19 +602,11 @@ mod tests { let run_ref = format!("refs/mt/runs/{node_hash}/{old_token}"); assert!(refs.contains(&run_ref), "run ref лишається: {refs}"); - let run_md = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:mt/demo/run_001.md")], - ) - .unwrap(); + let run_md = blob(fixture.origin.path(), &run_ref, "mt/demo/run_001.md"); assert!(run_md.contains("result: handoff"), "{run_md}"); - let journal = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ) - .unwrap(); + let journal = blob(fixture.origin.path(), &run_ref, ".nitra/session.jsonl"); assert_eq!( - journal, "{\"seq\":0}", + journal, "{\"seq\":0}\n", "повний журнал (без checkpoint-стрипу): {journal}" ); } @@ -755,14 +685,23 @@ mod tests { // run_001.md — handoff-маркер першого хосту; run_002.md — success // від другого. Без розривів NNN попри зміну хоста. - let main_files = super::git( - Path::new(fixture.origin.path()), - &["ls-tree", "-r", "--name-only", "main"], + assert!(blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/run_002.md" ) - .unwrap(); - assert!(main_files.contains("mt/demo/run_002.md"), "{main_files}"); - assert!(main_files.contains("mt/demo/fact_002.md"), "{main_files}"); - assert!(!main_files.contains(".nitra"), "{main_files}"); + .contains("result: success")); + assert!(blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/fact_002.md" + ) + .contains("## Summary")); + let main = mt_core::git::GitRepository::open(fixture.origin.path()).unwrap(); + let main_sha = main.resolve_ref("refs/heads/main").unwrap(); + assert!(main + .read_blob_at_commit(&main_sha, ".nitra/session.jsonl") + .is_err()); let refs = fixture.remote_refs(); assert!( diff --git a/crates/agent-server/tests/graph_wiring.rs b/crates/agent-server/tests/graph_wiring.rs index 3835eba..d7bc4d5 100644 --- a/crates/agent-server/tests/graph_wiring.rs +++ b/crates/agent-server/tests/graph_wiring.rs @@ -2,53 +2,19 @@ //! журнал у run ref, DoneSession → fenced publish, ReleaseSession → пауза. //! Все герметично: bare-репо як origin, скриптований runner, реальний WS. -use std::path::Path; -use std::process::Command; use std::sync::Arc; use agent_protocol::{Envelope, Event}; use agent_server::{serve, AppState, ApprovalGate, GraphConfig, ScriptedTurnRunner, SessionHost}; use chrono::Utc; use futures::SinkExt; +use mt_core::test_support::{blob, commit_all, push_head, remote_refs, TestRepo}; use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; mod common; use common::next_json; -fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); -} - -fn sh_out(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); - String::from_utf8_lossy(&out.stdout).trim().to_string() -} - struct Fixture { #[allow(dead_code)] origin: tempfile::TempDir, @@ -62,19 +28,11 @@ impl Fixture { /// bare-origin + робочий клон із вузлом `mt/demo` + WS-сервер із /// graph-мостом і скриптованим runner-ом (по одній відповіді на хід). async fn start(responses: Vec<&str>) -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); + let TestRepo { origin, work } = TestRepo::new(); std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); + commit_all(work.path(), "add task"); + push_head(work.path(), "refs/heads/main"); let state_dir = tempfile::tempdir().unwrap(); let sessions = Arc::new(SessionHost::new(state_dir.path().to_path_buf()).unwrap()); @@ -94,7 +52,7 @@ impl Fixture { } fn remote_refs(&self) -> String { - sh_out(self.work.path(), &["ls-remote", "origin"]) + remote_refs(self.work.path()).join("\n") } } @@ -150,19 +108,14 @@ async fn user_message_attaches_and_done_publishes() { let mut journal = String::new(); for _ in 0..50 { let refs = fixture.remote_refs(); - if let Some(run_ref) = refs - .lines() - .find(|line| line.contains("refs/mt/runs/")) - .and_then(|line| line.split_whitespace().nth(1)) - { - let out = Command::new("git") - .arg("-C") - .arg(fixture.origin.path()) - .args(["show", &format!("{run_ref}:.nitra/session.jsonl")]) - .output() - .unwrap(); - if out.status.success() { - journal = String::from_utf8_lossy(&out.stdout).into_owned(); + if let Some(run_ref) = refs.lines().find(|line| line.contains("refs/mt/runs/")) { + let repository = mt_core::git::GitRepository::open(fixture.origin.path()).unwrap(); + if let Ok(commit) = repository.resolve_ref(run_ref) { + if let Ok(bytes) = repository.read_blob_at_commit(&commit, ".nitra/session.jsonl") { + journal = String::from_utf8(bytes).unwrap(); + } + } + if !journal.is_empty() { break; } } @@ -188,14 +141,24 @@ async fn user_message_attaches_and_done_publishes() { let refs = fixture.remote_refs(); assert!(!refs.contains("refs/mt/claims/"), "{refs}"); assert!(!refs.contains("refs/mt/runs/"), "{refs}"); - let main_files = sh_out( - fixture.origin.path(), - &["ls-tree", "-r", "--name-only", "main"], - ); - assert!(!main_files.contains(".nitra"), "{main_files}"); + let main = mt_core::git::GitRepository::open(fixture.origin.path()).unwrap(); + let main_sha = main.resolve_ref("refs/heads/main").unwrap(); + assert!(main + .read_blob_at_commit(&main_sha, ".nitra/session.jsonl") + .is_err()); // Контрактні артефакти спроби синтезовано (graph.md). - assert!(main_files.contains("mt/demo/run_001.md"), "{main_files}"); - assert!(main_files.contains("mt/demo/fact_001.md"), "{main_files}"); + assert!(blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/run_001.md" + ) + .contains("result")); + assert!(blob( + fixture.origin.path(), + "refs/heads/main", + "mt/demo/fact_001.md" + ) + .contains("## Summary")); } /// ReleaseSession: пауза — claim знято (ClaimChanged без holder-а), diff --git a/crates/agent-server/tests/handoff_ws.rs b/crates/agent-server/tests/handoff_ws.rs index 9e9f3ad..420c0fe 100644 --- a/crates/agent-server/tests/handoff_ws.rs +++ b/crates/agent-server/tests/handoff_ws.rs @@ -4,37 +4,18 @@ //! Хід на хості 1 → `handoff_node` → `resume_node` на хості 2 з тим самим //! тікетом → журнал успадкований, наступний хід продовжує seq без розривів. -use std::path::Path; -use std::process::Command; use std::sync::Arc; use agent_protocol::{Envelope, Event}; use agent_server::{serve, AppState, ApprovalGate, GraphConfig, ScriptedTurnRunner, SessionHost}; use futures::SinkExt; +use mt_core::test_support::{commit_all, push_head, TestRepo}; use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; mod common; use common::{next_json, WsStream}; -fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); -} - /// Bare-origin + робочий клон із вузлом `mt/demo` — спільна координатна /// точка «двох хостів» (обидва працюють у тому самому локальному клоні; /// реалістичніше було б два окремі клони, але git-операції йдуть через @@ -47,19 +28,11 @@ struct Fixture { impl Fixture { fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); + let TestRepo { origin, work } = TestRepo::new(); std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); + commit_all(work.path(), "add task"); + push_head(work.path(), "refs/heads/main"); Self { origin, work } } diff --git a/crates/mt-core/Cargo.toml b/crates/mt-core/Cargo.toml index 322ff60..724a3af 100644 --- a/crates/mt-core/Cargo.toml +++ b/crates/mt-core/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +test-support = ["dep:tempfile"] + [lib] name = "mt_core" @@ -14,6 +17,8 @@ serde.workspace = true serde_json.workspace = true chrono.workspace = true sha2.workspace = true +gix.workspace = true +tempfile = { workspace = true, optional = true } [dev-dependencies] tempfile.workspace = true diff --git a/crates/mt-core/src/claims.rs b/crates/mt-core/src/claims.rs index 7380dcc..bd2d748 100644 --- a/crates/mt-core/src/claims.rs +++ b/crates/mt-core/src/claims.rs @@ -2,17 +2,19 @@ //! //! Ownership вузла живе у GitHub custom refs `refs/mt/claims/`; //! claim ref вказує на commit із `.mt-claim.yml`. Модуль дає read-модель: -//! node-hash, читання remote claims через git CLI і зіставлення з вузлами. +//! node-hash, читання remote claims через native gix і зіставлення з вузлами. use std::path::{Path, PathBuf}; -use std::process::Command; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; -use crate::frontmatter::parse_yaml; +use crate::{ + frontmatter::parse_yaml, + git::{compat, ClaimRef, GitRepository}, +}; /// Префікс claim refs (дефолт `.mt.json` → `claim_ref_prefix`). pub const CLAIM_REF_PREFIX: &str = "refs/mt/claims"; @@ -42,8 +44,9 @@ pub const RUN_REF_PREFIX: &str = "refs/mt/runs"; /// Git top-level, що містить `tasks_dir` (`git rev-parse --show-toplevel`). pub fn discover_repo_root(tasks_dir: &Path) -> Result { - let out = git(tasks_dir, &["rev-parse", "--show-toplevel"])?; - Ok(PathBuf::from(out.trim())) + GitRepository::open(tasks_dir) + .and_then(|repository| repository.repo_root()) + .map_err(|error| error.to_string()) } /// Канонічний шлях `tasks_dir` відносно `repo_root`, POSIX-нормалізований @@ -135,23 +138,6 @@ pub fn parse_claim(node_hash: &str, yaml: &str, grace_sec: i64, now: DateTime Result { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - /// Поля `.mt-claim.yml`, які runner контролює при acquire/renew/takeover. pub struct ClaimFields<'a> { pub node: &'a str, @@ -188,53 +174,15 @@ fn claim_yaml(f: &ClaimFields) -> String { ) } -/// Як [`git`], але пише `stdin` у дочірній процес (для `hash-object`/`mktree`). -fn git_stdin(repo: &Path, args: &[&str], stdin: &str) -> Result { - use std::io::Write; - use std::process::Stdio; - - let mut child = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - child - .stdin - .take() - .expect("stdin piped") - .write_all(stdin.as_bytes()) - .map_err(|e| format!("git {}: write stdin: {e}", args.join(" ")))?; - let out = child - .wait_with_output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - /// Пише claim-коміт (blob → tree → commit-tree) без checkout/індексу — /// придатне для headless runner без робочого дерева проєкту. `parent` — /// `base_sha` для першого claim, попередній claim-коміт для renew/takeover /// (спека: «Новий claim commit має parent = попередній claim commit»). fn create_claim_commit(repo: &Path, parent: &str, fields: &ClaimFields) -> Result { - let blob_sha = git_stdin(repo, &["hash-object", "-w", "--stdin"], &claim_yaml(fields))?; - let tree_entry = format!("100644 blob {blob_sha}\t.mt-claim.yml\n"); - let tree_sha = git_stdin(repo, &["mktree"], &tree_entry)?; let message = format!("mt: claim {}", fields.node); - let commit_sha = git( - repo, - &["commit-tree", &tree_sha, "-p", parent, "-m", &message], - )?; - Ok(commit_sha.trim().to_string()) + GitRepository::open(repo) + .and_then(|repository| repository.write_claim_commit(parent, &claim_yaml(fields), &message)) + .map_err(|error| error.to_string()) } /// Підсумок CAS-push claim ref. `accepted: false` — інший runner виграв @@ -245,38 +193,15 @@ pub struct ClaimPush { pub commit_sha: String, } -/// Розрізняє "інший runner виграв гонку" (force-with-lease rejection) від -/// системної помилки (мережа/автентифікація) за текстом stderr git push. -fn is_lease_rejection(stderr: &str) -> bool { - stderr.contains("stale info") - || stderr.contains("[rejected]") - || stderr.contains("already exists") - || stderr.contains("fetch first") -} - fn push_claim_ref( repo: &Path, node_hash: &str, new_sha: &str, expected: Option<&str>, ) -> Result { - let refname = format!("{CLAIM_REF_PREFIX}/{node_hash}"); - let lease = format!("--force-with-lease={refname}:{}", expected.unwrap_or("")); - let refspec = format!("{new_sha}:{refname}"); - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(["push", &lease, "origin", &refspec]) - .output() - .map_err(|e| format!("git push claim: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if is_lease_rejection(&stderr) { - return Ok(false); - } - Err(format!("git push claim: {}", stderr.trim())) + let refname = ClaimRef::new(node_hash).map_err(|error| error.to_string())?; + compat::push_with_expected(repo, refname.as_str(), new_sha, expected) + .map_err(|error| error.to_string()) } /// Create-only CAS (спека, крок 3): приймається лише якщо `refs/mt/claims/` @@ -315,49 +240,33 @@ pub fn renew_or_takeover_claim( /// власник exact `claim_sha`. `accepted: false` не є помилкою: означає, що /// claim вже загублено (takeover), publish цього runner-а мав бути fenced. pub fn release_claim(repo: &Path, node_hash: &str, claim_sha: &str) -> Result { - let refname = format!("{CLAIM_REF_PREFIX}/{node_hash}"); - let lease = format!("--force-with-lease={refname}:{claim_sha}"); - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(["push", &lease, "origin", &format!(":{refname}")]) - .output() - .map_err(|e| format!("git push --delete claim: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if is_lease_rejection(&stderr) { - return Ok(false); - } - Err(format!("git push --delete claim: {}", stderr.trim())) + let refname = ClaimRef::new(node_hash).map_err(|error| error.to_string())?; + compat::delete_with_expected(repo, refname.as_str(), claim_sha) + .map_err(|error| error.to_string()) } /// Читає remote claims: `ls-remote` → fetch claim refs → `.mt-claim.yml` з /// кожного claim-коміта. `grace_sec` — буфер перед takeover (`claim_grace_sec`). pub fn fetch_remote_claims(repo_root: &Path, grace_sec: i64) -> Result, String> { - let ls = git( - repo_root, - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/*")], - )?; - let refs = parse_ls_remote(&ls, CLAIM_REF_PREFIX); + let repository = GitRepository::open(repo_root).map_err(|error| error.to_string())?; + let refs = repository + .fetch_claim_refs() + .map_err(|error| error.to_string())? + .into_iter() + .map(|(node_hash, sha)| RemoteClaimRef { node_hash, sha }) + .collect::>(); if refs.is_empty() { return Ok(Vec::new()); } - // Custom refs не покриваються стандартним refspec — тягнемо явно (спека). - git( - repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{CLAIM_REF_PREFIX}/*:{CLAIM_REF_PREFIX}/*"), - ], - )?; let now = Utc::now(); let mut claims = Vec::new(); for r in refs { - let yaml = git(repo_root, &["show", &format!("{}:.mt-claim.yml", r.sha)])?; + let yaml = String::from_utf8( + repository + .read_blob_at_commit(&r.sha, ".mt-claim.yml") + .map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("claim YAML is not UTF-8: {error}"))?; claims.push(parse_claim(&r.node_hash, &yaml, grace_sec, now)); } Ok(claims) @@ -366,7 +275,7 @@ pub fn fetch_remote_claims(repo_root: &Path, grace_sec: i64) -> Result(node: &'a str, token: &'a str, base_sha: &'a str) -> ClaimFields<'a> { @@ -448,10 +357,7 @@ mod tests { assert_ne!(renewed.commit_sha, first.commit_sha); // Ланцюг claim-комітів: parent нового = попередній claim-коміт (не main). - let parent = output( - repo.work.path(), - &["rev-parse", &format!("{}^", renewed.commit_sha)], - ); + let parent = first_parent(repo.work.path(), &renewed.commit_sha); assert_eq!(parent, first.commit_sha); // Застаріле знання SHA (гонка вже пройшла) → CAS відхиляє. @@ -484,20 +390,16 @@ mod tests { "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" ) .unwrap()); - let ls = git( + assert!(remote_ref_exists( repo.work.path(), - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/{hash}")], - ) - .unwrap(); - assert!(!ls.trim().is_empty()); + &format!("{CLAIM_REF_PREFIX}/{hash}") + )); assert!(release_claim(repo.work.path(), &hash, &claim.commit_sha).unwrap()); - let ls = git( + assert!(!remote_ref_exists( repo.work.path(), - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/{hash}")], - ) - .unwrap(); - assert!(ls.trim().is_empty()); + &format!("{CLAIM_REF_PREFIX}/{hash}") + )); } #[test] @@ -534,6 +436,14 @@ mod tests { assert!(!claims[0].expired); } + #[test] + fn fetch_remote_claims_reports_a_missing_origin() { + let repo = tempfile::tempdir().unwrap(); + gix::init(repo.path()).unwrap(); + + assert!(fetch_remote_claims(repo.path(), 60).is_err()); + } + #[test] fn node_hash_is_20_hex_and_stable() { let h = node_hash("mt", "research/analyze"); diff --git a/crates/mt-core/src/docs/claims.md b/crates/mt-core/src/docs/claims.md index ef58822..7576133 100644 --- a/crates/mt-core/src/docs/claims.md +++ b/crates/mt-core/src/docs/claims.md @@ -15,7 +15,7 @@ docgen: ## Поведінка node_hash: генерує 20-символьний хеш SHA-256 з поєднання `tasks_root` та `node_path`. -discover_repo_root: знаходить кореневий каталог репозиторію за допомогою `git rev-parse --show-toplevel`. +discover_repo_root: знаходить кореневий каталог репозиторію через native `gix` discovery. tasks_root_relative: обчислює канонічний шлях `tasks_dir` відносно `repo_root`, нормалізуючи POSIX-роздільники. RemoteClaimRef: структура для зберігання хешу вузла та SHA. parse_ls_remote: парсить вивід `git ls-remote` для вилучення `RemoteClaimRef`. @@ -24,20 +24,20 @@ lease_expired: перевіряє, чи прострочений термін д parse_claim: будує `ClaimInfo` з YAML-вмісту `.mt-claim.yml`. ClaimFields: структура для зберігання полів, які контролює runner, включаючи бейз-хеш та посилання на першу коміт. ClaimPush: структура для збереження результату CAS-push, включаючи статус прийняття. -acquire_claim: створює новий claim-коміт і намагається опублікувати його через `git push`. +acquire_claim: створює claim-коміт через native `gix`, а exact CAS-публікацію делегує вузькому `git::compat`. renew_or_takeover_claim: створює новий claim-коміт на основі попереднього, використовуючи `old_claim_sha` для авторизації. release_claim: намагається видалити (delete) claim-референс, якщо він належить поточному власнику. -fetch_remote_claims: зчитує remote claims через `git ls-remote`, виконує `fetch` та парсить YAML для генерації `ClaimInfo`. +fetch_remote_claims: зчитує remote claims через native `gix` fetch та парсить YAML для генерації `ClaimInfo`. ## Публічний API **node_hash** — 20-символьний хеш SHA-256 з `\0`. -**discover_repo_root** — кореневий каталог репозиторію через `git rev-parse --show-toplevel`. +**discover_repo_root** — кореневий каталог репозиторію через native `gix` discovery. **RemoteClaimRef**, **ClaimInfo**, **ClaimFields**, **ClaimPush** — структури для представлення claim-даних. **parse_ls_remote**, **parse_claim** — парсинг `git ls-remote` і `.mt-claim.yml` у `ClaimInfo`. **lease_expired** — прострочення lease з урахуванням grace period. **acquire_claim**, **renew_or_takeover_claim**, **release_claim** — CAS-цикл claim-коміту: створення, поновлення/перехоплення за `old_claim_sha`, видалення за `claim_sha`. -**fetch_remote_claims** — читає всі remote claims (`fetch` + парсинг кожного claim-коміту). +**fetch_remote_claims** — читає всі remote claims native `gix` fetch-операцією та парсить кожен claim-коміт. ## Гарантії поведінки diff --git a/crates/mt-core/src/git/compat.rs b/crates/mt-core/src/git/compat.rs new file mode 100644 index 0000000..730773c --- /dev/null +++ b/crates/mt-core/src/git/compat.rs @@ -0,0 +1,306 @@ +//! Вузька межа Git porcelain для операцій, яких немає у pinned `gix` 0.86. +//! +//! Дозволені capability: staging/commit, custom-ref CAS, worktree lifecycle, +//! branch config, rebase, atomic publish і локальний fast-forward. Це не +//! загальний API для виконання довільних Git-команд. + +use std::{path::Path, process::Command}; + +use super::{GitError, SignaturePolicy}; + +/// Додає усі зміни до index і створює commit за заданою identity policy. +/// Повертає `false`, коли worktree чистий після staging. +pub fn commit_all_if_changed( + repo: &Path, + message: &str, + signature: SignaturePolicy, +) -> Result { + run(repo, ["add", "-A"])?; + commit_staged_if_changed(repo, message, signature) +} + +/// Створює commit з уже staged index змінами. +pub fn commit_staged_if_changed( + repo: &Path, + message: &str, + signature: SignaturePolicy, +) -> Result { + let staged = Command::new("git") + .arg("-C") + .arg(repo) + .args(["diff", "--cached", "--quiet"]) + .status() + .map_err(|error| GitError::from_error(format!("git diff --cached: {error}")))?; + if staged.success() { + return Ok(false); + } + if staged.code() != Some(1) { + return Err(GitError::from_error("git diff --cached failed")); + } + + let mut command = Command::new("git"); + command + .arg("-C") + .arg(repo) + .args(["commit", "-q", "-m", message]); + if signature == SignaturePolicy::Runner { + command + .env("GIT_AUTHOR_NAME", "mt-runner") + .env("GIT_AUTHOR_EMAIL", "mt-runner@localhost") + .env("GIT_COMMITTER_NAME", "mt-runner") + .env("GIT_COMMITTER_EMAIL", "mt-runner@localhost"); + } + let out = command + .output() + .map_err(|error| GitError::from_error(format!("git commit: {error}")))?; + if out.status.success() { + Ok(true) + } else { + Err(GitError::from_error(format!( + "git commit: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))) + } +} + +/// Прибирає шлях лише з index, не видаляючи його з worktree. +pub fn remove_from_index(repo: &Path, path: &str) -> Result { + run( + repo, + ["rm", "-r", "-q", "--cached", "--ignore-unmatch", path], + )?; + let staged = Command::new("git") + .arg("-C") + .arg(repo) + .args(["diff", "--cached", "--quiet"]) + .status() + .map_err(|error| GitError::from_error(format!("git diff --cached: {error}")))?; + Ok(staged.code() == Some(1)) +} + +/// Публікує `new_target` у `refname` лише за очікуваного remote target. +/// `None` означає create-only ref. +pub fn push_with_expected( + repo: &Path, + refname: &str, + new_target: &str, + expected_target: Option<&str>, +) -> Result { + let lease = format!( + "--force-with-lease={refname}:{}", + expected_target.unwrap_or("") + ); + let refspec = format!("{new_target}:{refname}"); + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["push", &lease, "origin", &refspec]) + .output() + .map_err(|error| GitError::from_error(format!("git push: {error}")))?; + if out.status.success() { + return Ok(true); + } + let stderr = String::from_utf8_lossy(&out.stderr); + if is_lease_rejection(&stderr) { + return Ok(false); + } + Err(GitError::from_error(format!("git push: {}", stderr.trim()))) +} + +/// Видаляє custom ref лише якщо remote ще вказує на `expected_target`. +pub fn delete_with_expected( + repo: &Path, + refname: &str, + expected_target: &str, +) -> Result { + let lease = format!("--force-with-lease={refname}:{expected_target}"); + let refspec = format!(":{refname}"); + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["push", &lease, "origin", &refspec]) + .output() + .map_err(|error| GitError::from_error(format!("git push --delete: {error}")))?; + if out.status.success() { + return Ok(true); + } + let stderr = String::from_utf8_lossy(&out.stderr); + if is_lease_rejection(&stderr) { + return Ok(false); + } + Err(GitError::from_error(format!( + "git push --delete: {}", + stderr.trim() + ))) +} + +/// Публікує поточний `HEAD` у custom ref без CAS, для run checkpoint. +pub fn push_head(repo: &Path, refname: &str) -> Result<(), GitError> { + let refspec = format!("HEAD:{refname}"); + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["push", "origin", &refspec]) + .output() + .map_err(|error| GitError::from_error(format!("git push: {error}")))?; + if out.status.success() { + return Ok(()); + } + Err(GitError::from_error(format!( + "git push: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))) +} + +/// Виконує allow-listed worktree lifecycle porcelain (`add`, `remove`, `list`, `prune`). +pub fn worktree(repo: &Path, args: &[&str]) -> Result { + if !matches!(args.first(), Some(&"add" | &"remove" | &"list" | &"prune")) { + return Err(GitError::from_error( + "unsupported compat worktree operation", + )); + } + output(repo, "worktree", args) +} + +/// Видаляє гілку після успішного видалення її worktree. +pub fn delete_branch(repo: &Path, branch: &str) -> Result<(), GitError> { + run(repo, ["branch", "-D", branch]) +} + +/// Зберігає локальний опис developer branch. +pub fn set_branch_description( + repo: &Path, + branch: &str, + description: &str, +) -> Result<(), GitError> { + run( + repo, + [ + "config", + "--local", + &format!("branch.{branch}.description"), + description, + ], + ) +} + +/// Читає локальний опис developer branch. +pub fn branch_description(repo: &Path, branch: &str) -> Result, GitError> { + let key = format!("branch.{branch}.description"); + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["config", "--local", "--get", &key]) + .output() + .map_err(|error| GitError::from_error(format!("git config: {error}")))?; + if out.status.success() { + return Ok(Some( + String::from_utf8_lossy(&out.stdout).trim().to_string(), + )); + } + if out.status.code() == Some(1) { + return Ok(None); + } + Err(GitError::from_error(format!( + "git config: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))) +} + +/// Rebase-ить worktree на задану базу та прибирає незавершений rebase при помилці. +pub fn rebase_onto(repo: &Path, base: &str) -> Result<(), GitError> { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rebase", base]) + .output() + .map_err(|error| GitError::from_error(format!("git rebase: {error}")))?; + if out.status.success() { + return Ok(()); + } + let message = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rebase", "--abort"]) + .output(); + Err(GitError::from_error(format!("git rebase: {message}"))) +} + +/// Виконує fenced atomic push; `false` означає очікувану lease rejection. +pub fn push_atomic(repo: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .map_err(|error| GitError::from_error(format!("git push --atomic: {error}")))?; + if out.status.success() { + return Ok(true); + } + let stderr = String::from_utf8_lossy(&out.stderr); + if is_lease_rejection(&stderr) { + return Ok(false); + } + Err(GitError::from_error(format!( + "git push --atomic: {}", + stderr.trim() + ))) +} + +/// Best-effort fast-forward локальної `main` після успішного publish. +pub fn fast_forward_main(repo: &Path, result_sha: &str) -> Result<(), GitError> { + let branch = output(repo, "rev-parse", &["--abbrev-ref", "HEAD"])?; + if branch != "main" { + return Ok(()); + } + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["merge", "--ff-only", result_sha]) + .output() + .map_err(|error| GitError::from_error(format!("git merge --ff-only: {error}")))?; + Ok(()) +} + +fn is_lease_rejection(stderr: &str) -> bool { + stderr.contains("stale info") + || stderr.contains("[rejected]") + || stderr.contains("already exists") + || stderr.contains("fetch first") +} + +fn run(repo: &Path, args: [&str; N]) -> Result<(), GitError> { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .map_err(|error| GitError::from_error(format!("git: {error}")))?; + if out.status.success() { + Ok(()) + } else { + Err(GitError::from_error(format!( + "git: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))) + } +} + +fn output(repo: &Path, command: &str, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .arg(command) + .args(args) + .output() + .map_err(|error| GitError::from_error(format!("git {command}: {error}")))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(GitError::from_error(format!( + "git {command}: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))) + } +} diff --git a/crates/mt-core/src/git/error.rs b/crates/mt-core/src/git/error.rs new file mode 100644 index 0000000..1d0cb7f --- /dev/null +++ b/crates/mt-core/src/git/error.rs @@ -0,0 +1,25 @@ +//! Помилки native Git facade. + +use std::fmt::{Display, Formatter}; + +/// Помилка відкриття репозиторію або native Git операції. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitError { + message: String, +} + +impl GitError { + pub(crate) fn from_error(error: impl Display) -> Self { + Self { + message: error.to_string(), + } + } +} + +impl Display for GitError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for GitError {} diff --git a/crates/mt-core/src/git/mod.rs b/crates/mt-core/src/git/mod.rs new file mode 100644 index 0000000..e680550 --- /dev/null +++ b/crates/mt-core/src/git/mod.rs @@ -0,0 +1,378 @@ +//! Єдина межа взаємодії `mt-core` з Git. + +mod error; +mod refs; + +/// Вузькі Git CLI capability, яких pinned `gix` ще не надає. +pub mod compat; + +use std::{ + path::{Path, PathBuf}, + sync::atomic::AtomicBool, +}; + +pub use error::GitError; +pub use refs::{ClaimRef, RunRef}; + +/// Policy identity для commit-ів, створених runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignaturePolicy { + /// Детермінований identity автономного runner-а. + Runner, + /// Identity з Git config активного worktree. + Configured, +} + +/// Відкритий Git-репозиторій для наступних native `gix` операцій. +pub struct GitRepository { + #[allow(dead_code)] // Наступні facade-операції Task 2 споживатимуть handle. + repo: gix::Repository, +} + +impl GitRepository { + /// Відкриває репозиторій, якому належить переданий шлях. + pub fn open(path: &Path) -> Result { + let repo = gix::discover(path).map_err(GitError::from_error)?; + Ok(Self { repo }) + } + + /// Комітить усі зміни worktree або повертає `None`, якщо змін немає. + pub fn commit_all_if_changed( + &self, + message: &str, + signature: SignaturePolicy, + ) -> Result, GitError> { + let worktree = self.repo_root()?; + if !compat::commit_all_if_changed(&worktree, message, signature)? { + return Ok(None); + } + self.repo + .head_id() + .map_err(GitError::from_error) + .map(|id| Some(id.to_string())) + } + + /// Прибирає шлях лише з index, залишаючи його у worktree. + pub fn remove_from_index(&self, path: &str) -> Result { + compat::remove_from_index(&self.repo_root()?, path) + } + + /// Комітить вже staged зміни або повертає `None`, якщо index чистий. + pub fn commit_staged_if_changed( + &self, + message: &str, + signature: SignaturePolicy, + ) -> Result, GitError> { + let worktree = self.repo_root()?; + if !compat::commit_staged_if_changed(&worktree, message, signature)? { + return Ok(None); + } + self.repo + .head_id() + .map_err(GitError::from_error) + .map(|id| Some(id.to_string())) + } + + /// Повертає абсолютний шлях робочого дерева репозиторію. + pub fn repo_root(&self) -> Result { + self.repo + .workdir() + .map(Path::to_path_buf) + .ok_or_else(|| GitError::from_error("bare repositories have no worktree")) + } + + /// Повертає fetch URL віддаленого `origin`, якщо він налаштований. + pub fn origin_url(&self) -> Result, GitError> { + let remote = match self.repo.try_find_remote("origin") { + Some(Ok(remote)) => remote, + None => return Ok(None), + Some(Err(error)) => return Err(GitError::from_error(error)), + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(ToString::to_string)) + } + + /// Повертає object ID, на який вказує повний ref. + pub fn resolve_ref(&self, reference: &str) -> Result { + self.repo + .find_reference(reference) + .map_err(GitError::from_error) + .map(|reference| reference.id().to_string()) + } + + /// Повертає повне ім'я поточної гілки або `None` для detached `HEAD`. + pub fn head_ref_name(&self) -> Result, GitError> { + self.repo + .head_name() + .map_err(GitError::from_error) + .map(|name| name.map(|name| name.to_string())) + } + + /// Читає blob за шляхом у дереві заданого коміту. + pub fn read_blob_at_commit(&self, commit: &str, path: &str) -> Result, GitError> { + let commit = gix::ObjectId::from_hex(commit.as_bytes()).map_err(GitError::from_error)?; + let tree = self + .repo + .find_commit(commit) + .map_err(GitError::from_error)? + .tree() + .map_err(GitError::from_error)?; + let entry = tree + .lookup_entry(path.split('/')) + .map_err(GitError::from_error)? + .ok_or_else(|| GitError::from_error(format!("path not found in commit: {path}")))?; + + entry + .object() + .map_err(GitError::from_error)? + .try_into_blob() + .map_err(GitError::from_error) + .map(|mut blob| blob.take_data()) + } + + /// Створює commit з одним `.mt-claim.yml` без checkout або index. + pub fn write_claim_commit( + &self, + parent: &str, + yaml: &str, + message: &str, + ) -> Result { + let blob = self.repo.write_blob(yaml).map_err(GitError::from_error)?; + let tree = gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: ".mt-claim.yml".into(), + oid: blob.detach(), + }], + }; + let tree = self.repo.write_object(tree).map_err(GitError::from_error)?; + let parent = gix::ObjectId::from_hex(parent.as_bytes()).map_err(GitError::from_error)?; + let signature = gix::actor::SignatureRef { + name: b"mt".as_slice().into(), + email: b"mt@localhost".as_slice().into(), + time: "0 +0000", + }; + let commit = self + .repo + .new_commit_as(signature, signature, message, tree, [parent]) + .map_err(GitError::from_error)?; + Ok(commit.id.to_string()) + } + + /// Завантажує custom claim refs з `origin` і повертає їх advertised object IDs. + pub fn fetch_claim_refs(&self) -> Result, GitError> { + let refspec_text = "+refs/mt/claims/*:refs/mt/claims/*"; + let refspec = + gix::refspec::parse(refspec_text.into(), gix::refspec::parse::Operation::Fetch) + .map_err(GitError::from_error)? + .to_owned(); + let remote = self + .repo + .find_remote("origin") + .map_err(GitError::from_error)?; + let mut options = gix::remote::ref_map::Options::default(); + options.extra_refspecs.push(refspec); + let connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(GitError::from_error)?; + let prepared = connection + .prepare_fetch(gix::progress::Discard, options) + .map_err(GitError::from_error)?; + let refs = prepared + .ref_map() + .remote_refs + .iter() + .filter_map(|reference| match reference { + gix::protocol::handshake::Ref::Direct { + full_ref_name, + object, + } => { + let name = String::from_utf8_lossy(full_ref_name.as_ref()).into_owned(); + name.strip_prefix("refs/mt/claims/") + .filter(|name| !name.is_empty() && !name.contains('/')) + .map(|name| (name.to_string(), object.to_string())) + } + _ => None, + }) + .collect::>(); + + if refs.is_empty() { + return Ok(refs); + } + prepared + .receive(gix::progress::Discard, &AtomicBool::new(false)) + .map_err(GitError::from_error)?; + Ok(refs) + } + + /// Завантажує exact remote ref у вказаний локальний ref через native gix transport. + pub fn fetch_refspec(&self, refspec_text: &str) -> Result<(), GitError> { + let refspec = + gix::refspec::parse(refspec_text.into(), gix::refspec::parse::Operation::Fetch) + .map_err(GitError::from_error)? + .to_owned(); + let remote = self + .repo + .find_remote("origin") + .map_err(GitError::from_error)?; + let mut options = gix::remote::ref_map::Options::default(); + options.extra_refspecs.push(refspec); + let connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(GitError::from_error)?; + let prepared = connection + .prepare_fetch(gix::progress::Discard, options) + .map_err(GitError::from_error)?; + prepared + .receive(gix::progress::Discard, &AtomicBool::new(false)) + .map_err(GitError::from_error)?; + Ok(()) + } + + /// Перевіряє, чи remote `origin` рекламує точний ref, без запису локального ref. + pub fn remote_has_ref(&self, reference: &str) -> Result { + let refspec_text = format!("+{reference}:{reference}"); + let refspec = gix::refspec::parse( + refspec_text.as_str().into(), + gix::refspec::parse::Operation::Fetch, + ) + .map_err(GitError::from_error)? + .to_owned(); + let remote = self + .repo + .find_remote("origin") + .map_err(GitError::from_error)?; + let mut options = gix::remote::ref_map::Options::default(); + options.extra_refspecs.push(refspec); + let connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(GitError::from_error)?; + let prepared = connection + .prepare_fetch(gix::progress::Discard, options) + .map_err(GitError::from_error)?; + Ok(prepared + .ref_map() + .remote_refs + .iter() + .any(|remote_ref| match remote_ref { + gix::protocol::handshake::Ref::Direct { full_ref_name, .. } => { + String::from_utf8_lossy(&full_ref_name[..]) == reference + } + _ => false, + })) + } + + /// Повертає всі refs, які remote `origin` рекламує, без local fetch state. + pub fn advertised_remote_refs(&self) -> Result, GitError> { + let remote = self + .repo + .find_remote("origin") + .map_err(GitError::from_error)?; + let mut options = gix::remote::ref_map::Options::default(); + for refspec_text in [ + "+refs/heads/*:refs/remotes/origin/*", + "+refs/mt/claims/*:refs/mt/claims/*", + "+refs/mt/runs/*:refs/mt/runs/*", + ] { + options.extra_refspecs.push( + gix::refspec::parse(refspec_text.into(), gix::refspec::parse::Operation::Fetch) + .map_err(GitError::from_error)? + .to_owned(), + ); + } + let connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(GitError::from_error)?; + let prepared = connection + .prepare_fetch(gix::progress::Discard, options) + .map_err(GitError::from_error)?; + Ok(prepared + .ref_map() + .remote_refs + .iter() + .filter_map(|remote_ref| match remote_ref { + gix::protocol::handshake::Ref::Direct { full_ref_name, .. } => { + Some(String::from_utf8_lossy(&full_ref_name[..]).into_owned()) + } + _ => None, + }) + .collect()) + } + + /// Повертає імена головного та всіх доступних linked worktree. + pub fn linked_worktrees(&self) -> Result, GitError> { + let main_repo = self.repo.main_repo().map_err(GitError::from_error)?; + let mut worktrees = main_repo + .workdir() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(String::from) + .into_iter() + .collect::>(); + + for worktree in main_repo.worktrees().map_err(GitError::from_error)? { + let path = worktree.base().map_err(GitError::from_error)?; + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + worktrees.push(name.to_string()); + } + } + Ok(worktrees) + } + + /// Повертає native `gix` handle тільки всередині `mt-core`. + #[allow(dead_code)] // Публічні native-операції додаються поетапно. + pub(crate) fn inner(&self) -> &gix::Repository { + &self.repo + } +} + +#[cfg(test)] +mod tests { + use super::GitRepository; + use crate::test_support::TestRepo; + + #[test] + fn discovers_root_origin_ref_and_blob_without_git_cli() { + let fixture = TestRepo::new(); + let repository = GitRepository::open(fixture.work.path()).unwrap(); + let main = fixture.main_sha(); + + assert_eq!(repository.repo_root().unwrap(), fixture.work.path()); + assert!(repository.origin_url().unwrap().is_some()); + assert_eq!(repository.resolve_ref("refs/heads/main").unwrap(), main); + assert_eq!( + repository.read_blob_at_commit(&main, "README.md").unwrap(), + b"x" + ); + } + + #[test] + fn origin_is_none_when_no_remote_is_configured() { + let fixture = tempfile::tempdir().unwrap(); + gix::init(fixture.path()).unwrap(); + + let repository = GitRepository::open(fixture.path()).unwrap(); + + assert_eq!(repository.origin_url().unwrap(), None); + } + + #[test] + fn linked_worktrees_include_the_main_worktree() { + let fixture = TestRepo::new(); + let repository = GitRepository::open(fixture.work.path()).unwrap(); + + assert_eq!(repository.linked_worktrees().unwrap().len(), 1); + assert!(repository.linked_worktrees().unwrap().contains( + &fixture + .work + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string() + )); + } +} diff --git a/crates/mt-core/src/git/refs.rs b/crates/mt-core/src/git/refs.rs new file mode 100644 index 0000000..d47f960 --- /dev/null +++ b/crates/mt-core/src/git/refs.rs @@ -0,0 +1,58 @@ +//! Типізовані custom refs протоколу виконання `mt`. + +use super::GitError; + +const CLAIM_PREFIX: &str = "refs/mt/claims/"; +const RUN_PREFIX: &str = "refs/mt/runs/"; + +/// Custom ref, що серіалізує володіння вузлом задачі. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimRef(String); + +impl ClaimRef { + /// Створює claim ref лише з lower-hex node hash. + pub fn new(node_hash: &str) -> Result { + if node_hash.is_empty() + || !node_hash + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(invalid_ref_component("node hash")); + } + Ok(Self(format!("{CLAIM_PREFIX}{node_hash}"))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Custom ref, що зберігає checkpoint окремого запуску. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunRef(String); + +impl RunRef { + /// Створює run ref для валідного node hash і непрозорого token без `/`. + pub fn new(node_hash: &str, token: &str) -> Result { + let claim = ClaimRef::new(node_hash)?; + if token.is_empty() + || token.contains('/') + || token.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(invalid_ref_component("run token")); + } + let node_hash = claim + .as_str() + .strip_prefix(CLAIM_PREFIX) + .expect("known prefix"); + Ok(Self(format!("{RUN_PREFIX}{node_hash}/{token}"))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn invalid_ref_component(name: &str) -> GitError { + GitError::from_error(format!("invalid {name}")) +} diff --git a/crates/mt-core/src/lib.rs b/crates/mt-core/src/lib.rs index 0bfde99..39610da 100644 --- a/crates/mt-core/src/lib.rs +++ b/crates/mt-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod claims; pub mod config; pub mod directory; pub mod frontmatter; +pub mod git; pub mod ledger; pub mod lifecycle; pub mod nnn; @@ -17,8 +18,9 @@ pub mod publish; pub mod runner; pub mod signal; pub mod spawn; -#[cfg(test)] -mod test_support; +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub mod test_support; pub mod worktree; // ── Types ───────────────────────────────────────────────────────────────────── @@ -772,35 +774,12 @@ pub fn scan_tasks(tasks_dir: String, worktrees: Vec) -> Result Vec { - let output = std::process::Command::new("git") - .args(["worktree", "list", "--porcelain"]) - .current_dir(start) - .output(); - match output { - Ok(out) if out.status.success() => { - parse_worktree_list(&String::from_utf8_lossy(&out.stdout)) - } - _ => vec![], - } -} - -/// Парсить `git worktree list --porcelain` → імена worktree (останній компонент шляху). -pub fn parse_worktree_list(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let path = line.strip_prefix("worktree ")?.trim(); - let name = path.rsplit(['/', '\\']).next().unwrap_or(""); - if name.is_empty() { - None - } else { - Some(name.to_string()) - } - }) - .collect() + git::GitRepository::open(start) + .and_then(|repository| repository.linked_worktrees()) + .unwrap_or_default() } /// Знаходить усі mt/ директорії у репо, починаючи від `start_dir`. @@ -1514,16 +1493,6 @@ mod tests { assert_eq!(sanitize(""), ""); } - // ── worktree list parsing ── - #[test] - fn parse_worktree_list_extracts_names() { - let out = "worktree /repo\nHEAD abc\n\nworktree /repo/.worktrees/my-task-123\nHEAD def\n"; - assert_eq!( - parse_worktree_list(out), - vec!["repo".to_string(), "my-task-123".to_string()] - ); - } - // ── create_task (write-side) ── /// /mt with optional .mt.json defaults in the project root; returns (root, mt_dir). diff --git a/crates/mt-core/src/publish.rs b/crates/mt-core/src/publish.rs index a20d779..4dc08da 100644 --- a/crates/mt-core/src/publish.rs +++ b/crates/mt-core/src/publish.rs @@ -4,42 +4,14 @@ //! аудитором однаково; тут — генерична реалізація над готовим worktree. use std::path::Path; -use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use crate::claims::{CLAIM_REF_PREFIX, RUN_REF_PREFIX}; - -fn git(repo: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -fn git_status(repo: &Path, args: &[&str]) -> Result<(bool, String), String> { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - Ok(( - out.status.success(), - String::from_utf8_lossy(&out.stderr).trim().to_string(), - )) -} +use crate::{ + claims::{CLAIM_REF_PREFIX, RUN_REF_PREFIX}, + git::{compat, GitRepository}, +}; /// Вхід одного fenced-publish запиту. pub struct PublishRequest<'a> { @@ -67,10 +39,6 @@ pub struct PublishOutcome { pub attempts: u32, } -fn is_lease_rejection(stderr: &str) -> bool { - stderr.contains("stale info") || stderr.contains("[rejected]") || stderr.contains("fetch first") -} - /// Псевдо-джиттер без зовнішньої залежності `rand`: молодші біти системного /// часу в наносекундах — достатньо для розсіювання конкурентних retry. fn jitter_ms(spread_ms: u64) -> u64 { @@ -96,20 +64,17 @@ pub fn fenced_publish( ) -> Result { let claim_ref = format!("{CLAIM_REF_PREFIX}/{}", req.node_hash); let run_ref = format!("{RUN_REF_PREFIX}/{}/{}", req.node_hash, req.token); + let repository = GitRepository::open(repo_root).map_err(|error| error.to_string())?; for attempt in 0..retry_max.max(1) { - git(repo_root, &["fetch", "--quiet", "origin", "main"])?; + repository + .fetch_refspec("+refs/heads/main:refs/remotes/origin/main") + .map_err(|error| error.to_string())?; // Custom ref — явний fetch (спека: стандартний refspec його не покриває). - let (claim_fetch_ok, _) = git_status( - repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{claim_ref}:{claim_ref}"), - ], - )?; - if !claim_fetch_ok { + if repository + .fetch_refspec(&format!("+{claim_ref}:{claim_ref}")) + .is_err() + { // Claim ref зник (звільнено/протух і прибрано) — без claim publish // неможливий: fencing failed, не системна помилка. return Ok(PublishOutcome { @@ -121,7 +86,17 @@ pub fn fenced_publish( } // Fencing: claim ref усе ще на exact SHA цього runner-а. - let current_claim_sha = git(repo_root, &["rev-parse", &claim_ref])?; + let current_claim_sha = match repository.resolve_ref(&claim_ref) { + Ok(sha) => sha, + Err(_) => { + return Ok(PublishOutcome { + published: false, + fenced: true, + result_sha: None, + attempts: attempt + 1, + }) + } + }; if current_claim_sha != req.claim_sha { return Ok(PublishOutcome { published: false, @@ -131,39 +106,41 @@ pub fn fenced_publish( }); } - let main_sha_before = git(repo_root, &["rev-parse", "origin/main"])?; + let main_sha_before = repository + .resolve_ref("refs/remotes/origin/main") + .map_err(|error| error.to_string())?; // Rebase worktree на origin/main. Конфлікт → merge-conflict (термінально // для цієї спроби; викликач фіксує result: merge-conflict, без retry тут). - let (rebase_ok, rebase_err) = git_status(req.worktree, &["rebase", "origin/main"])?; - if !rebase_ok { - let _ = git_status(req.worktree, &["rebase", "--abort"]); - return Err(format!("rebase conflict on publish: {rebase_err}")); - } + compat::rebase_onto(req.worktree, "origin/main") + .map_err(|error| format!("rebase conflict on publish: {error}"))?; - let result_sha = git(req.worktree, &["rev-parse", "HEAD"])?; + let result_sha = GitRepository::open(req.worktree) + .and_then(|worktree| worktree.resolve_ref("HEAD")) + .map_err(|error| error.to_string())?; let lease_main = format!("--force-with-lease=refs/heads/main:{main_sha_before}"); let lease_claim = format!("--force-with-lease={claim_ref}:{}", req.claim_sha); let lease_run = format!("--force-with-lease={run_ref}:{}", req.run_ref_sha_before); - let out = Command::new("git") - .arg("-C") - .arg(req.worktree) - .args([ + let main_refspec = format!("{result_sha}:refs/heads/main"); + let claim_refspec = format!(":{claim_ref}"); + let run_refspec = format!(":{run_ref}"); + if compat::push_atomic( + req.worktree, + &[ "push", "--atomic", &lease_main, &lease_claim, &lease_run, "origin", - &format!("{result_sha}:refs/heads/main"), - &format!(":{claim_ref}"), - &format!(":{run_ref}"), - ]) - .output() - .map_err(|e| format!("git push --atomic: {e}"))?; - - if out.status.success() { + &main_refspec, + &claim_refspec, + &run_refspec, + ], + ) + .map_err(|error| error.to_string())? + { // Best-effort: fast-forward локальний main, якщо саме на ньому і // це чистий ff (щоб GUI live-скан одразу бачив опублікований // результат, а не чекав ручного pull — не критично при невдачі). @@ -176,10 +153,6 @@ pub fn fenced_publish( }); } - let stderr = String::from_utf8_lossy(&out.stderr); - if !is_lease_rejection(&stderr) { - return Err(format!("git push --atomic: {}", stderr.trim())); - } // Race програно — backoff+jitter, наступна ітерація перечитує стан. let backoff = base_ms.saturating_mul(1u64 << attempt.min(16)); std::thread::sleep(Duration::from_millis(backoff + jitter_ms(base_ms))); @@ -199,22 +172,14 @@ pub fn fenced_publish( /// (інша гілка, брудне дерево, конфлікт) — дані вже в `origin/main`, /// це лише питання видимості локально. fn sync_local_main(repo_root: &Path, result_sha: &str) -> Result<(), String> { - let current_branch = git(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])?; - if current_branch != "main" { - return Ok(()); - } - let (ok, _) = git_status(repo_root, &["merge", "--ff-only", result_sha])?; - if !ok { - return Ok(()); - } - Ok(()) + compat::fast_forward_main(repo_root, result_sha).map_err(|error| error.to_string()) } #[cfg(test)] mod tests { use super::*; use crate::claims::{acquire_claim, node_hash, ClaimFields}; - use crate::test_support::{output, TestRepo}; + use crate::test_support::{commit_all, remote_ref_exists, TestRepo}; use crate::worktree::{create_run_worktree, push_run_ref}; fn setup(repo: &TestRepo) -> (String, crate::claims::ClaimPush, std::path::PathBuf) { @@ -252,8 +217,7 @@ mod tests { let base = repo.main_sha(); std::fs::write(wt.join("result.txt"), "done").unwrap(); - crate::test_support::run(&wt, &["add", "."]); - crate::test_support::run(&wt, &["commit", "-q", "-m", "mt: result"]); + commit_all(&wt, "mt: result"); let req = PublishRequest { worktree: &wt, @@ -268,18 +232,15 @@ mod tests { assert!(outcome.result_sha.is_some()); // main на remote просунувся; claim/run ref прибрані. - let remote_main = output( + assert!(remote_ref_exists(repo.work.path(), "refs/heads/main")); + assert!(!remote_ref_exists( repo.work.path(), - &["ls-remote", "origin", "refs/heads/main"], - ); - assert!(remote_main.contains(outcome.result_sha.as_ref().unwrap())); - let claims_left = output( + &format!("refs/mt/claims/{hash}") + )); + assert!(!remote_ref_exists( repo.work.path(), - &["ls-remote", "origin", "refs/mt/claims/*"], - ); - assert!(claims_left.is_empty()); - let runs_left = output(repo.work.path(), &["ls-remote", "origin", "refs/mt/runs/*"]); - assert!(runs_left.is_empty()); + &format!("refs/mt/runs/{hash}/tok1") + )); // Локальний main (той самий work-клон, HEAD на main) синхронізовано. assert!(repo.work.path().join("result.txt").is_file()); @@ -313,8 +274,7 @@ mod tests { .unwrap(); std::fs::write(wt.join("result.txt"), "done").unwrap(); - crate::test_support::run(&wt, &["add", "."]); - crate::test_support::run(&wt, &["commit", "-q", "-m", "mt: result"]); + commit_all(&wt, "mt: result"); let req = PublishRequest { worktree: &wt, diff --git a/crates/mt-core/src/runner.rs b/crates/mt-core/src/runner.rs index 5e1d16e..27687fb 100644 --- a/crates/mt-core/src/runner.rs +++ b/crates/mt-core/src/runner.rs @@ -35,6 +35,7 @@ use crate::config::{ AgentCliEnv, }; use crate::frontmatter::parse_front_matter; +use crate::git::GitRepository; use crate::nnn::pad_nnn; use crate::publish::{fenced_publish, PublishRequest}; use crate::signal::{self, next_run_nnn, write_run_fm}; @@ -458,23 +459,6 @@ fn md_section(text: &str, name: &str) -> Option { (!s.is_empty()).then_some(s) } -fn git(dir: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - fn iso_now() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -506,28 +490,12 @@ fn worktrees_dir_path(repo_root: &Path, config: &serde_json::Value) -> PathBuf { /// Комітить усі зміни worktree (fact/run/plan/тощо); "нема що комітити" — /// не помилка (виконавець теоретично міг не лишити diff). fn commit_worktree(worktree: &Path, message: &str) -> Result<(), String> { - git(worktree, &["add", "-A"])?; - let status = git(worktree, &["status", "--porcelain"])?; - if status.is_empty() { - return Ok(()); - } - let out = Command::new("git") - .arg("-C") - .arg(worktree) - .args(["commit", "-q", "-m", message]) - .env("GIT_AUTHOR_NAME", "mt-runner") - .env("GIT_AUTHOR_EMAIL", "mt-runner@localhost") - .env("GIT_COMMITTER_NAME", "mt-runner") - .env("GIT_COMMITTER_EMAIL", "mt-runner@localhost") - .output() - .map_err(|e| format!("git commit: {e}"))?; - if !out.status.success() { - return Err(format!( - "git commit: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(()) + crate::git::GitRepository::open(worktree) + .and_then(|repository| { + repository.commit_all_if_changed(message, crate::git::SignaturePolicy::Runner) + }) + .map(|_| ()) + .map_err(|error| error.to_string()) } /// Результат одного спавну під watchdog-ом. @@ -639,8 +607,13 @@ pub fn run_node_env( let publish_retry_max = fm_u64(&config, "publish_retry_max").unwrap_or(8) as u32; let publish_retry_base_ms = fm_u64(&config, "publish_retry_base_ms").unwrap_or(250); - git(&repo_root, &["fetch", "--quiet", "origin", "main"])?; - let base_sha = git(&repo_root, &["rev-parse", "origin/main"])?; + let repository = GitRepository::open(&repo_root).map_err(|error| error.to_string())?; + repository + .fetch_refspec("+refs/heads/main:refs/remotes/origin/main") + .map_err(|error| error.to_string())?; + let base_sha = repository + .resolve_ref("refs/remotes/origin/main") + .map_err(|error| error.to_string())?; let token = fresh_token(); let runner_id = format!("mt-runner/{}", std::process::id()); @@ -872,9 +845,8 @@ mod tests { /// потрібно для `run_node()`: worktree чекаутиться саме з `origin/main`. fn node(tmp: &Path, path: &str) { node_files_only(tmp, path); - crate::test_support::run(tmp, &["add", "."]); - crate::test_support::run(tmp, &["commit", "-q", "-m", &format!("add {path}")]); - crate::test_support::run(tmp, &["push", "-q", "origin", "main"]); + crate::test_support::commit_all(tmp, &format!("add {path}")); + crate::test_support::push_head(tmp, "refs/heads/main"); } /// Тіло фейкового `claude`, що пише валідний fact поточної спроби @@ -1037,11 +1009,10 @@ mod tests { assert!(!crate::has_running_marker(&root.join("solo"))); // Опубліковано в origin/main: claim/run ref прибрані, коміт на remote. - let claims = crate::test_support::output( + assert!(!crate::test_support::remote_ref_exists( repo.work.path(), - &["ls-remote", "origin", "refs/mt/claims/*"], - ); - assert!(claims.is_empty()); + "refs/mt/claims/00000000000000000000" + )); // Локальний main (той самий work-клон) підхопив публікацію. assert!(root.join("solo/fact_001.md").is_file()); let run = fs::read_to_string(root.join("solo/run_001.md")).unwrap(); @@ -1095,9 +1066,8 @@ mod tests { ) .unwrap(); fs::write(dir.join("a.md"), "schema_version: 1\n").unwrap(); - crate::test_support::run(repo.work.path(), &["add", "."]); - crate::test_support::run(repo.work.path(), &["commit", "-q", "-m", "add gated"]); - crate::test_support::run(repo.work.path(), &["push", "-q", "origin", "main"]); + crate::test_support::commit_all(repo.work.path(), "add gated"); + crate::test_support::push_head(repo.work.path(), "refs/heads/main"); let r = root.to_string_lossy().into_owned(); let mut result = String::new(); diff --git a/crates/mt-core/src/test_support.rs b/crates/mt-core/src/test_support.rs index bb2d684..b6923d1 100644 --- a/crates/mt-core/src/test_support.rs +++ b/crates/mt-core/src/test_support.rs @@ -1,46 +1,80 @@ //! Герметичні git-фікстури для тестів `claims`/`publish` — bare-репозиторій //! як "origin" + звичайний клон з одним комітом на `main`. Тільки для тестів //! (`#[cfg(test)]`), нуль впливу на реальний runtime. -#![cfg(test)] - -use std::path::Path; -use std::process::Command; - -/// Запускає git-команду в `dir`, панікує з stderr при ненульовому exit-коді. -pub fn run(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "test@test.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "test@test.local") - .output() - .unwrap_or_else(|e| panic!("git {args:?}: {e}")); - if !out.status.success() { - panic!( - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } +#![cfg(any(test, feature = "test-support"))] + +use std::{io::Write, path::Path}; + +use crate::git::{compat, GitRepository, SignaturePolicy}; + +/// Комітить усі поточні зміни з детермінованою test identity. +pub fn commit_all(dir: &Path, message: &str) { + GitRepository::open(dir) + .unwrap() + .commit_all_if_changed(message, SignaturePolicy::Runner) + .unwrap(); } -/// Як [`run`], але повертає trimmed stdout. -pub fn output(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap_or_else(|e| panic!("git {args:?}: {e}")); - if !out.status.success() { - panic!( - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - String::from_utf8_lossy(&out.stdout).trim().to_string() +/// Публікує поточний `HEAD` у `origin/` через вузький compat API. +pub fn push_head(dir: &Path, refname: &str) { + compat::push_head(dir, refname).unwrap(); +} + +/// Повертає SHA поточного `HEAD`. +pub fn head(dir: &Path) -> String { + GitRepository::open(dir) + .unwrap() + .resolve_ref("HEAD") + .unwrap() +} + +/// Повертає повну назву гілки або `None` для detached `HEAD`. +pub fn head_branch(dir: &Path) -> Option { + GitRepository::open(dir).unwrap().head_ref_name().unwrap() +} + +/// Повертає SHA першого parent commit-а. +pub fn first_parent(dir: &Path, commit: &str) -> String { + let repository = GitRepository::open(dir).unwrap(); + let commit = repository + .resolve_ref(commit) + .unwrap_or_else(|_| commit.into()); + let commit = gix::ObjectId::from_hex(commit.as_bytes()).unwrap(); + let parent = repository + .inner() + .find_commit(commit) + .unwrap() + .parent_ids() + .next() + .unwrap() + .to_string(); + parent +} + +/// Читає blob із заданого commit/ref без porcelain `git show`. +#[allow(dead_code)] +pub fn blob(dir: &Path, commit: &str, path: &str) -> String { + let repository = GitRepository::open(dir).unwrap(); + let commit = repository + .resolve_ref(commit) + .unwrap_or_else(|_| commit.into()); + String::from_utf8(repository.read_blob_at_commit(&commit, path).unwrap()).unwrap() +} + +/// Перевіряє наявність remote ref через native advertisement, без local cache. +pub fn remote_ref_exists(dir: &Path, reference: &str) -> bool { + GitRepository::open(dir) + .unwrap() + .remote_has_ref(reference) + .unwrap() +} + +/// Повертає назви refs, які рекламує `origin`. +pub fn remote_refs(dir: &Path) -> Vec { + GitRepository::open(dir) + .unwrap() + .advertised_remote_refs() + .unwrap() } /// Bare "origin" + звичайний клон із одним комітом на `main`, віддалений @@ -54,26 +88,45 @@ pub struct TestRepo { pub work: tempfile::TempDir, } +impl Default for TestRepo { + fn default() -> Self { + Self::new() + } +} + impl TestRepo { pub fn new() -> Self { let origin = tempfile::tempdir().unwrap(); - run(origin.path(), &["init", "--bare", "-q", "-b", "main"]); + gix::init_bare(origin.path()).unwrap(); + std::fs::write(origin.path().join("HEAD"), "ref: refs/heads/main\n").unwrap(); let work = tempfile::tempdir().unwrap(); - run(work.path(), &["init", "-q", "-b", "main"]); + gix::init(work.path()).unwrap(); + std::fs::write(work.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let config = work.path().join(".git/config"); + std::fs::OpenOptions::new() + .append(true) + .open(&config) + .unwrap() + .write_all( + format!( + "\n[remote \"origin\"]\n\turl = {}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", + origin.path().display() + ) + .as_bytes(), + ) + .unwrap(); std::fs::write(work.path().join("README.md"), "x").unwrap(); - run(work.path(), &["add", "."]); - run(work.path(), &["commit", "-q", "-m", "init"]); - run( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - run(work.path(), &["push", "-q", "origin", "main"]); + commit_all(work.path(), "init"); + push_head(work.path(), "refs/heads/main"); Self { origin, work } } pub fn main_sha(&self) -> String { - output(self.work.path(), &["rev-parse", "main"]) + GitRepository::open(self.work.path()) + .unwrap() + .resolve_ref("refs/heads/main") + .unwrap() } } diff --git a/crates/mt-core/src/worktree.rs b/crates/mt-core/src/worktree.rs index 8fc3259..e9f92ad 100644 --- a/crates/mt-core/src/worktree.rs +++ b/crates/mt-core/src/worktree.rs @@ -3,30 +3,16 @@ //! скрипт», крок 5: detached worktree від зафіксованого `base_sha`). use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::SystemTime; use serde::{Deserialize, Serialize}; +#[cfg(test)] use crate::claims::RUN_REF_PREFIX; -use crate::sanitize; - -fn git(repo: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} +use crate::{ + git::{compat, RunRef}, + sanitize, +}; /// Префікс worktree для задачі: `sanitize(task_path.replace('/', '-'))`. fn worktree_prefix(task_path: &str) -> String { @@ -67,25 +53,19 @@ pub fn create_run_worktree( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - git( + compat::worktree( repo_root, - &[ - "worktree", - "add", - "--detach", - &path.to_string_lossy(), - base_sha, - ], - )?; + &["add", "--detach", &path.to_string_lossy(), base_sha], + ) + .map_err(|error| error.to_string())?; Ok(path) } /// Публікує локальний run ref для recovery/handoff (спека, крок 5): /// `refs/mt/runs//` ← поточний HEAD worktree. pub fn push_run_ref(repo_root: &Path, node_hash: &str, token: &str) -> Result<(), String> { - let refname = format!("{RUN_REF_PREFIX}/{node_hash}/{token}"); - git(repo_root, &["push", "origin", &format!("HEAD:{refname}")])?; - Ok(()) + let refname = RunRef::new(node_hash, token).map_err(|error| error.to_string())?; + compat::push_head(repo_root, refname.as_str()).map_err(|error| error.to_string()) } /// Видаляє remote run ref (після успішного publish або при cleanup невдалої @@ -96,22 +76,9 @@ pub fn delete_run_ref( token: &str, expected_sha: &str, ) -> Result { - let refname = format!("{RUN_REF_PREFIX}/{node_hash}/{token}"); - let lease = format!("--force-with-lease={refname}:{expected_sha}"); - let out = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["push", &lease, "origin", &format!(":{refname}")]) - .output() - .map_err(|e| format!("git push --delete run ref: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("stale info") || stderr.contains("[rejected]") { - return Ok(false); - } - Err(format!("git push --delete run ref: {}", stderr.trim())) + let refname = RunRef::new(node_hash, token).map_err(|error| error.to_string())?; + compat::delete_with_expected(repo_root, refname.as_str(), expected_sha) + .map_err(|error| error.to_string()) } /// Прибирає worktree після завершення спроби (success — завжди; failure — @@ -124,13 +91,13 @@ pub fn remove_run_worktree(repo_root: &Path, path: &Path) -> Result<(), String> /// run-флоу: `force: false` відмовляє на брудному worktree (git-поведінка за /// замовчуванням), `force: true` — примусово (як [`remove_run_worktree`]). pub fn remove_worktree(repo_root: &Path, path: &Path, force: bool) -> Result<(), String> { - let mut args = vec!["worktree", "remove"]; + let mut args = vec!["remove"]; if force { args.push("--force"); } let path_str = path.to_string_lossy(); args.push(&path_str); - git(repo_root, &args)?; + compat::worktree(repo_root, &args).map_err(|error| error.to_string())?; Ok(()) } @@ -147,17 +114,11 @@ pub fn create_dev_worktree( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - git( + compat::worktree( repo_root, - &[ - "worktree", - "add", - "-b", - &branch, - &path.to_string_lossy(), - base, - ], - )?; + &["add", "-b", &branch, &path.to_string_lossy(), base], + ) + .map_err(|error| error.to_string())?; Ok(path) } @@ -165,7 +126,7 @@ pub fn create_dev_worktree( #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorktreeEntry { pub path: String, - /// Останній компонент шляху (те саме, що повертає [`crate::parse_worktree_list`]). + /// Останній компонент шляху (те саме, що повертає [`crate::discover_worktrees`]). pub name: String, pub head: String, /// `None` — detached HEAD. @@ -248,21 +209,9 @@ pub fn parse_worktree_entries(output: &str) -> Vec { /// `mt worktree list`: усі worktree репо (структуровано, з branch/lock/prune-станом). pub fn list_worktrees(repo_root: &Path) -> Result, String> { - let out = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["worktree", "list", "--porcelain"]) - .output() - .map_err(|e| format!("git worktree list: {e}"))?; - if !out.status.success() { - return Err(format!( - "git worktree list: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(parse_worktree_entries(&String::from_utf8_lossy( - &out.stdout, - ))) + let output = + compat::worktree(repo_root, &["list", "--porcelain"]).map_err(|error| error.to_string())?; + Ok(parse_worktree_entries(&output)) } /// Записує локальний опис developer-гілки, який Git зберігає в @@ -272,39 +221,13 @@ pub fn set_branch_description( branch: &str, description: &str, ) -> Result<(), String> { - git( - repo_root, - &[ - "config", - "--local", - &format!("branch.{branch}.description"), - description, - ], - )?; - Ok(()) + compat::set_branch_description(repo_root, branch, description) + .map_err(|error| error.to_string()) } /// Повертає локальний Git-опис гілки; відсутній ключ є штатним `None`. pub fn branch_description(repo_root: &Path, branch: &str) -> Result, String> { - let key = format!("branch.{branch}.description"); - let out = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["config", "--local", "--get", &key]) - .output() - .map_err(|e| format!("git config --get {key}: {e}"))?; - if out.status.success() { - return Ok(Some( - String::from_utf8_lossy(&out.stdout).trim().to_string(), - )); - } - if out.status.code() == Some(1) { - return Ok(None); - } - Err(format!( - "git config --get {key}: {}", - String::from_utf8_lossy(&out.stderr).trim() - )) + compat::branch_description(repo_root, branch).map_err(|error| error.to_string()) } /// Прибирає developer-worktree і гілку, якою володіє саме `mt`: `mt/`. @@ -326,26 +249,14 @@ pub fn remove_dev_worktree( )); } remove_worktree(repo_root, Path::new(&entry.path), force)?; - git(repo_root, &["branch", "-D", &branch])?; + compat::delete_branch(repo_root, &branch).map_err(|error| error.to_string())?; Ok(()) } /// `mt worktree prune`: `git worktree prune -v` — прибирає адміністративні /// записи worktree, чиї директорії видалено вручну. Повертає сирий вивід. pub fn prune_worktrees(repo_root: &Path) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["worktree", "prune", "-v"]) - .output() - .map_err(|e| format!("git worktree prune: {e}"))?; - if !out.status.success() { - return Err(format!( - "git worktree prune: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + compat::worktree(repo_root, &["prune", "-v"]).map_err(|error| error.to_string()) } /// Один запис `mt worktree inventory`: worktree, вік (за mtime директорії), @@ -423,11 +334,10 @@ mod tests { ) .unwrap(); assert!(path.join("README.md").is_file()); - let head = crate::test_support::output(&path, &["rev-parse", "HEAD"]); + let head = crate::test_support::head(&path); assert_eq!(head, base); // Detached — не на гілці: `--abbrev-ref HEAD` повертає літерал "HEAD". - let branch = crate::test_support::output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]); - assert_eq!(branch, "HEAD"); + assert_eq!(crate::test_support::head_branch(&path), None); } #[test] @@ -446,26 +356,17 @@ mod tests { // push_run_ref читає HEAD поточного репо (work), тож пушимо з worktree-контексту. push_run_ref(&path, "deadbeef", "tok1").unwrap(); - let ls = crate::test_support::output( + let reference = format!("{RUN_REF_PREFIX}/deadbeef/tok1"); + assert!(crate::test_support::remote_ref_exists( repo.work.path(), - &[ - "ls-remote", - "origin", - &format!("{RUN_REF_PREFIX}/deadbeef/tok1"), - ], - ); - assert!(!ls.is_empty()); + &reference + )); assert!(delete_run_ref(repo.work.path(), "deadbeef", "tok1", &base).unwrap()); - let ls = crate::test_support::output( + assert!(!crate::test_support::remote_ref_exists( repo.work.path(), - &[ - "ls-remote", - "origin", - &format!("{RUN_REF_PREFIX}/deadbeef/tok1"), - ], - ); - assert!(ls.is_empty()); + &reference + )); } #[test] @@ -528,8 +429,10 @@ mod tests { let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "my-task", "main").unwrap(); assert!(path.join("README.md").is_file()); - let branch = crate::test_support::output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]); - assert_eq!(branch, "mt/my-task"); + assert_eq!( + crate::test_support::head_branch(&path).as_deref(), + Some("refs/heads/mt/my-task") + ); } #[test] @@ -580,14 +483,10 @@ mod tests { assert!(branch_description(repo.work.path(), "mt/demo") .unwrap() .is_none()); - assert!(std::process::Command::new("git") - .arg("-C") - .arg(repo.work.path()) - .args(["show-ref", "--verify", "--quiet", "refs/heads/mt/demo"]) - .status() + assert!(crate::git::GitRepository::open(repo.work.path()) .unwrap() - .code() - .is_some_and(|code| code != 0)); + .resolve_ref("refs/heads/mt/demo") + .is_err()); } #[test] @@ -597,8 +496,7 @@ mod tests { let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); std::fs::write(path.join("feature.txt"), "worktree-only change").unwrap(); - crate::test_support::run(&path, &["add", "feature.txt"]); - crate::test_support::run(&path, &["commit", "-m", "feature work"]); + crate::test_support::commit_all(&path, "feature work"); let entry = list_worktrees(repo.work.path()) .unwrap() .into_iter() @@ -608,14 +506,10 @@ mod tests { remove_dev_worktree(repo.work.path(), &entry, "demo", false).unwrap(); assert!(!path.exists()); - assert!(std::process::Command::new("git") - .arg("-C") - .arg(repo.work.path()) - .args(["show-ref", "--verify", "--quiet", "refs/heads/mt/demo"]) - .status() + assert!(crate::git::GitRepository::open(repo.work.path()) .unwrap() - .code() - .is_some_and(|code| code != 0)); + .resolve_ref("refs/heads/mt/demo") + .is_err()); } #[test] diff --git a/crates/mt-core/tests/git_boundary.rs b/crates/mt-core/tests/git_boundary.rs new file mode 100644 index 0000000..b55956f --- /dev/null +++ b/crates/mt-core/tests/git_boundary.rs @@ -0,0 +1,59 @@ +use mt_core::git::{ClaimRef, RunRef}; +use std::path::Path; + +#[test] +fn claim_and_run_ref_names_are_validated() { + assert_eq!( + ClaimRef::new("abc123").unwrap().as_str(), + "refs/mt/claims/abc123" + ); + assert_eq!( + RunRef::new("abc123", "run-1").unwrap().as_str(), + "refs/mt/runs/abc123/run-1" + ); + assert!(ClaimRef::new("bad/ref").is_err()); + assert!(ClaimRef::new("ABC123").is_err()); + assert!(RunRef::new("abc123", "bad/token").is_err()); +} + +#[test] +fn compat_is_the_only_git_cli_boundary() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .unwrap(); + let mut offenders = Vec::new(); + scan_rust_sources(&root.join("crates"), root, &mut offenders); + + assert!( + offenders.is_empty(), + "direct Git CLI outside compat:\n{}", + offenders.join("\n") + ); +} + +fn scan_rust_sources(dir: &Path, root: &Path, offenders: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + scan_rust_sources(&path, root, offenders); + continue; + } + if path.extension().is_none_or(|extension| extension != "rs") + || path.ends_with("git/compat.rs") + { + continue; + } + let source = std::fs::read_to_string(&path).unwrap(); + if source.contains("Command::new(\"git\")") { + offenders.push(relative(&path, root)); + } + } +} + +fn relative(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .into_owned() +} diff --git a/crates/mt-napi/src/plan.rs b/crates/mt-napi/src/plan.rs index dcbc9bc..ad41a7c 100644 --- a/crates/mt-napi/src/plan.rs +++ b/crates/mt-napi/src/plan.rs @@ -68,8 +68,7 @@ pub struct PlanReviewJs { pub fn spawn_review(name: Option, root: Option) -> Result { let tasks_dir = resolve_tasks_dir(root.as_deref())?; let node_path = resolve_node_path(name, &tasks_dir)?; - let review = - mt_core::spawn::plan_review(&tasks_dir, &node_path).map_err(Error::from_reason)?; + let review = mt_core::spawn::plan_review(&tasks_dir, &node_path).map_err(Error::from_reason)?; Ok(PlanReviewJs { plan_file: review.plan_file, nnn: review.nnn as i64, @@ -114,5 +113,7 @@ pub fn spawn_reject( let node_path = resolve_node_path(name, &tasks_dir)?; let file = mt_core::spawn::spawn_reject(&tasks_dir, &node_path, &reason) .map_err(Error::from_reason)?; - Ok(SpawnRejectResult { rejected_file: file }) + Ok(SpawnRejectResult { + rejected_file: file, + }) } diff --git a/crates/mt/Cargo.toml b/crates/mt/Cargo.toml index 68dc546..1969ab5 100644 --- a/crates/mt/Cargo.toml +++ b/crates/mt/Cargo.toml @@ -19,4 +19,5 @@ serde_json.workspace = true chrono.workspace = true [dev-dependencies] +mt-core = { path = "../mt-core", features = ["test-support"] } tempfile.workspace = true diff --git a/crates/mt/src/commands/doctor.rs b/crates/mt/src/commands/doctor.rs index 92b8875..2d47d5f 100644 --- a/crates/mt/src/commands/doctor.rs +++ b/crates/mt/src/commands/doctor.rs @@ -36,12 +36,10 @@ pub fn run(_args: DoctorArgs, as_json: bool) -> Result<(), String> { ok: true, detail: root.display().to_string(), }); - let origin = std::process::Command::new("git") - .arg("-C") - .arg(&root) - .args(["remote", "get-url", "origin"]) - .output(); - let has_origin = origin.map(|o| o.status.success()).unwrap_or(false); + let has_origin = mt_core::git::GitRepository::open(&root) + .and_then(|repository| repository.origin_url()) + .map(|origin| origin.is_some()) + .unwrap_or(false); checks.push(Check { name: "git_origin".to_string(), ok: has_origin, diff --git a/crates/mt/tests/cli.rs b/crates/mt/tests/cli.rs index 645d20b..8577540 100644 --- a/crates/mt/tests/cli.rs +++ b/crates/mt/tests/cli.rs @@ -177,13 +177,10 @@ fn worktree_description_is_reported_and_owned_branch_is_removed() { &["worktree", "remove", "devwork", "--force"], ); assert!(remove.status.success(), "{}", stdout(&remove)); - let branch = std::process::Command::new("git") - .arg("-C") - .arg(repo.work.path()) - .args(["show-ref", "--verify", "--quiet", "refs/heads/mt/devwork"]) - .status() - .unwrap(); - assert!(!branch.success()); + assert!(mt_core::git::GitRepository::open(repo.work.path()) + .unwrap() + .resolve_ref("refs/heads/mt/devwork") + .is_err()); } #[test] diff --git a/crates/mt/tests/common/mod.rs b/crates/mt/tests/common/mod.rs index e51b960..aa4f7f8 100644 --- a/crates/mt/tests/common/mod.rs +++ b/crates/mt/tests/common/mod.rs @@ -4,54 +4,10 @@ //! модуль — `#[cfg(test)]`-приватний усередині `mt-core` і недоступний //! іншим крейтам навіть у тестових білдах. +pub use mt_core::test_support::TestRepo; use std::path::{Path, PathBuf}; use std::process::Command; -pub fn git(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "test@test.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "test@test.local") - .output() - .unwrap_or_else(|e| panic!("git {args:?}: {e}")); - if !out.status.success() { - panic!( - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } -} - -pub struct TestRepo { - #[allow(dead_code)] - pub origin: tempfile::TempDir, - pub work: tempfile::TempDir, -} - -impl TestRepo { - pub fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - git(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - - let work = tempfile::tempdir().unwrap(); - git(work.path(), &["init", "-q", "-b", "main"]); - std::fs::write(work.path().join("README.md"), "x").unwrap(); - git(work.path(), &["add", "."]); - git(work.path(), &["commit", "-q", "-m", "init"]); - git( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - git(work.path(), &["push", "-q", "origin", "main"]); - - Self { origin, work } - } -} - /// Шлях до скомпільованого бінарника `mt` цього тестового прогону. pub fn bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_mt")) diff --git a/docs/superpowers/plans/2026-07-29-gix-migration.md b/docs/superpowers/plans/2026-07-29-gix-migration.md new file mode 100644 index 0000000..6318004 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-gix-migration.md @@ -0,0 +1,439 @@ +# Gix Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Перевести всі підтримувані Git-взаємодії Rust-проєкту на `gix`, зберігши CAS claims, recovery refs, worktree isolation і fenced publish. + +**Architecture:** `mt-core::git` стане єдиною facade. Native `gix` реалізує discovery, refs, objects, commits, status, fetch/push і worktree inspection; `git::compat` лишається єдиним місцем shell-out для linked-worktree lifecycle, rebase та atomic multi-ref push. `mt`, `mt-napi` і `agent-server` споживають facade, а не `Command::new("git")`. + +**Tech Stack:** Rust 2021, `gix` 0.86 with `blocking-network-client`, existing `serde`, `tempfile`, Cargo integration tests. + +## Execution status (2026-07-29) + +Tasks 1–3 виконані native `gix`. Замість початково запланованих Task 4–5 +native реалізацій, точна перевірка pinned `gix` 0.86 показала дві відсутні +еквівалентні capability: exact remote lease для custom refs та high-level +worktree-to-index staging. Їх, разом із worktree lifecycle, rebase і atomic +multi-ref push, ізольовано в `mt-core::git::compat`; production callers +працюють виключно через facade. Task 8 static guard виконано для production +коду. Task 7 завершена: `mt-core/test-support` створює gix fixtures, а static +guard перевіряє весь Rust-код у `crates/`, включно з tests. + +## Global Constraints + +- Pinned dependency: `gix = "0.86"`; enabled features must include blocking network transport, index/status and worktree inspection. +- Keep exact ref schema: `refs/mt/claims/` and `refs/mt/runs//`. +- Remote race is `Rejected(LeaseMismatch)`, never a silently ignored error. +- Outside `crates/mt-core/src/git/compat.rs` production code must not contain `Command::new("git")`. +- `compat` is the sole production Git CLI boundary. Its documented allow-list is staging/commit, custom-ref CAS, worktree lifecycle, branch config, rebase, atomic publish and local fast-forward. +- Every changed Rust file receives current Ukrainian `docs/.md` documentation. +- Run the changelog gate after every committed unit; do not add generated `mt/lint-*` task drafts to a feature commit. + +--- + +### Task 1: Add the gix facade and static boundary + +**Files:** +- Modify: `Cargo.toml` +- Modify: `crates/mt-core/Cargo.toml` +- Create: `crates/mt-core/src/git/mod.rs` +- Create: `crates/mt-core/src/git/error.rs` +- Create: `crates/mt-core/src/git/refs.rs` +- Modify: `crates/mt-core/src/lib.rs` +- Create: `crates/mt-core/src/git/docs/mod.md` +- Create: `crates/mt-core/src/git/docs/error.md` +- Create: `crates/mt-core/src/git/docs/refs.md` +- Test: `crates/mt-core/tests/git_boundary.rs` + +**Interfaces:** +- Produces: `GitRepository::open(path: &Path) -> Result`. +- Produces: `ClaimRef`, `RunRef`, `RemoteUpdate`, `RemoteUpdateResult` and `GitError`. +- Consumed by every later task. + +- [x] **Step 1: Write failing boundary tests** + +```rust +#[test] +fn claim_and_run_ref_names_are_validated() { + assert_eq!(ClaimRef::new("abc123").unwrap().as_str(), "refs/mt/claims/abc123"); + assert!(RunRef::new("abc123", "bad/token").is_err()); +} +``` + +- [x] **Step 2: Run the new test and confirm failure** + +Run: `cargo test -p mt-core --test git_boundary` + +Expected: FAIL because the facade types do not exist. + +- [x] **Step 3: Add dependency and minimal typed facade** + +```toml +# Cargo.toml [workspace.dependencies] +gix = { version = "0.86", default-features = false, features = ["basic", "blocking-network-client", "index", "status", "worktree-mutation"] } +``` + +```rust +pub enum RemoteUpdateResult { Applied, Rejected(LeaseMismatch) } +pub struct ClaimRef(gix::refs::FullName); +pub struct RunRef(gix::refs::FullName); +pub struct GitRepository { repo: gix::Repository } +``` + +Make `lib.rs` export only `pub mod git;`; preserve old callers until their individual migration tasks. + +- [x] **Step 4: Run tests, format and clippy** + +Run: `cargo test -p mt-core --test git_boundary && cargo fmt --all -- --check && cargo clippy -p mt-core --all-targets --all-features -- -D warnings` + +Expected: PASS. + +- [x] **Step 5: Generate Rust file docs and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: + +```bash +git add Cargo.toml Cargo.lock crates/mt-core +git commit -m "feat(git): add gix facade boundary" +``` + +### Task 2: Replace discovery, origin and local-ref reads + +**Files:** +- Modify: `crates/mt-core/src/git/mod.rs` +- Modify: `crates/mt-core/src/claims.rs:40-47` +- Modify: `crates/mt/src/commands/doctor.rs:31-53` +- Modify: `crates/mt-core/src/lib.rs:775-790` +- Test: `crates/mt-core/src/git/mod.rs` +- Test: `crates/mt/tests/cli.rs` + +**Interfaces:** +- Consumes: `GitRepository` and typed refs from Task 1. +- Produces: `repo_root()`, `origin_url()`, `resolve_ref()`, `read_blob_at_commit()`, `linked_worktrees()`. + +- [x] **Step 1: Write failing repository tests** + +```rust +#[test] +fn opens_from_nested_linked_worktree_and_finds_main_repo() { /* fixture */ } +#[test] +fn origin_is_none_without_remote_and_a_url_when_configured() { /* fixture */ } +#[test] +fn resolve_ref_and_read_blob_do_not_shell_out() { /* fixture */ } +``` + +- [x] **Step 2: Confirm failure** + +Run: `cargo test -p mt-core --test git_repository` + +Expected: FAIL because the facade read methods do not exist. + +- [x] **Step 3: Implement native gix reads** + +Use `gix::discover()` to open from any task/worktree path, `main_repo()` for the shared repository, `find_remote("origin")` for doctor, `try_find_reference()` plus object lookup for SHA/blob reads, and `Repository::worktrees()` for inventory. Replace porcelain parsing, `git rev-parse`, `git remote get-url`, and `git worktree list` readers. Claim `git show` moves with Task 3. + +- [x] **Step 4: Verify CLI behavior** + +Run: `cargo test -p mt-core --test git_repository && cargo test -p mt --test cli doctor worktree -- --nocapture` + +Expected: PASS; missing origin remains a doctor failure with the existing user-facing text. + +- [x] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates/mt-core crates/mt && git commit -m "feat(git): use gix for repository discovery"` + +### Task 3: Move claim object creation and remote reads to gix + +**Files:** +- Modify: `crates/mt-core/src/git/mod.rs` +- Modify: `crates/mt-core/src/claims.rs:138-365` +- Test: `crates/mt-core/src/claims.rs` + +**Interfaces:** +- Produces: `write_commit(parent, files, message, signature)`, `list_remote_refs(pattern)`, `fetch_exact_ref(refname)`. +- Preserves: `ClaimInfo`, `ClaimFields`, `parse_ls_remote`, `acquire_claim` public behavior. + +- [x] **Step 1: Add failing object and remote-read tests** + +```rust +#[test] +fn claim_commit_contains_only_claim_yaml_and_expected_parent() { /* fixture */ } +#[test] +fn remote_claim_read_ignores_non_claim_refs_and_reports_transport_failure() { /* fixture */ } +``` + +- [x] **Step 2: Run the focused tests** + +Run: `cargo test -p mt-core claims::tests -- --nocapture` + +Expected: FAIL before native writer/remote reader exists. + +- [x] **Step 3: Implement without index or checkout** + +Build `.mt-claim.yml` as a blob, put it in a one-entry tree, create a commit with the specified parent, then list/fetch only `refs/mt/claims/*` through the configured `origin` remote. Decode the claim blob with gix object APIs instead of `git show`. + +- [x] **Step 4: Run claim regression tests** + +Run: `cargo test -p mt-core claims -- --nocapture` + +Expected: PASS, including expired-lease parsing and custom-ref filtering. + +- [x] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates/mt-core && git commit -m "feat(git): create and read claims with gix"` + +### Task 4: Migrate CAS claim and run-ref transport + +**Files:** +- Modify: `crates/mt-core/src/git/mod.rs` +- Modify: `crates/mt-core/src/claims.rs:240-334` +- Modify: `crates/mt-core/src/worktree.rs:79-111` +- Test: `crates/mt-core/tests/remote_cas.rs` + +**Interfaces:** +- Produces: `push_with_expected(refname, new_target, expected_target)` and `delete_with_expected(refname, expected_target)`. +- Consumed by: runner, agent server and fenced publish. + +- [ ] **Step 1: Write race tests against a local bare remote** + +```rust +#[test] +fn only_one_create_only_claim_is_applied() { /* two repositories, one remote */ } +#[test] +fn stale_renewal_and_delete_return_lease_mismatch() { /* exact old oid */ } +#[test] +fn run_ref_push_and_exact_delete_survive_reopen() { /* reopen fixture */ } +``` + +- [ ] **Step 2: Run the tests and confirm failure** + +Run: `cargo test -p mt-core --test remote_cas` + +Expected: FAIL because claims and run refs still execute Git CLI. + +- [ ] **Step 3: Implement typed transport mapping** + +Map native transport success to `Applied`; map the protocol's expected-old-object rejection to `Rejected(LeaseMismatch)`; return all other errors as `GitError`. Do not parse stderr and do not fall back to CLI for a non-atomic single-ref update. + +- [ ] **Step 4: Verify concurrency semantics** + +Run: `cargo test -p mt-core --test remote_cas && cargo test -p mt-core claims worktree -- --nocapture` + +Expected: PASS; exactly one concurrent acquire succeeds. + +- [ ] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates/mt-core && git commit -m "feat(git): move claim CAS transport to gix"` + +### Task 5: Migrate index, status and commits + +**Files:** +- Modify: `crates/mt-core/src/git/mod.rs` +- Modify: `crates/mt-core/src/runner.rs:461-530` +- Modify: `crates/agent-server/src/graph.rs:69-370` +- Test: `crates/mt-core/tests/git_commit.rs` +- Test: `crates/agent-server/tests/graph_wiring.rs` + +**Interfaces:** +- Produces: `commit_all_if_changed(message, SignaturePolicy) -> Result, GitError>` and `remove_from_index(path)`. +- Preserves: author/committer `mt-runner ` where current runner uses it. + +- [ ] **Step 1: Write failing status/commit tests** + +```rust +#[test] +fn clean_worktree_returns_none_without_creating_commit() { /* fixture */ } +#[test] +fn dirty_worktree_commits_all_files_with_runner_identity() { /* fixture */ } +#[test] +fn removing_nitra_from_index_keeps_worktree_journal_file() { /* fixture */ } +``` + +- [ ] **Step 2: Confirm failure** + +Run: `cargo test -p mt-core --test git_commit` + +Expected: FAIL before the facade owns index mutation. + +- [ ] **Step 3: Implement native index workflow** + +Use gix status/index APIs to detect changes, update the index from the worktree, write a tree and commit it with explicit signatures. For `.nitra` stripping, remove index entries only, preserving the untracked worktree journal file. + +- [ ] **Step 4: Replace both runner callers and verify** + +Run: `cargo test -p mt-core --test git_commit && cargo test -p agent-server graph_wiring -- --nocapture` + +Expected: PASS; no direct Git command remains in `runner.rs` or `agent-server/src/graph.rs` for commit/status/index work. + +- [ ] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates/mt-core crates/agent-server && git commit -m "feat(git): commit runner state through gix"` + +### Task 6: Isolate unsupported porcelain in compat + +**Files:** +- Create: `crates/mt-core/src/git/compat.rs` +- Modify: `crates/mt-core/src/git/mod.rs` +- Modify: `crates/mt-core/src/worktree.rs:52-157,264-280,320-345` +- Modify: `crates/mt-core/src/publish.rs:87-190` +- Create: `crates/mt-core/src/git/docs/compat.md` +- Test: `crates/mt-core/tests/git_compat.rs` + +**Interfaces:** +- Produces: the five allow-listed `compat` methods from Global Constraints. +- Native facade remains responsible for `fetch`, SHA resolution and post-publish local inspection. + +- [ ] **Step 1: Write contract tests** + +```rust +#[test] +fn compat_refuses_any_operation_outside_the_allow_list() { /* compile-time/module visibility */ } +#[test] +fn linked_worktree_remove_respects_force() { /* dirty fixture */ } +#[test] +fn rebase_conflict_is_reported_and_aborted() { /* divergent fixture */ } +#[test] +fn atomic_publish_never_leaves_main_and_claim_partially_updated() { /* rejected lease fixture */ } +``` + +- [ ] **Step 2: Confirm failure** + +Run: `cargo test -p mt-core --test git_compat` + +Expected: FAIL because compat module and contract boundaries do not exist. + +- [ ] **Step 3: Move only unsupported commands** + +Place `worktree add/remove/prune`, `rebase --abort`, and `push --atomic` inside private `compat`. Replace every other command in `worktree.rs` and `publish.rs` with facade calls. `push_atomic` accepts typed updates and exact expected OIDs; its error enum distinguishes lease rejection from transport failure. + +- [ ] **Step 4: Verify publish and worktree behavior** + +Run: `cargo test -p mt-core worktree publish -- --nocapture` + +Expected: PASS; no production `Command::new("git")` exists outside `git/compat.rs`. + +- [ ] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates/mt-core && git commit -m "refactor(git): isolate unsupported porcelain"` + +### Task 7: Convert all test fixtures and CLI tests to gix + +**Files:** +- Modify: `crates/mt-core/src/test_support.rs` +- Modify: `crates/mt/tests/common/mod.rs` +- Modify: `crates/mt/tests/cli.rs` +- Modify: `crates/agent-server/tests/handoff_ws.rs` +- Modify: `crates/agent-server/tests/graph_wiring.rs` +- Create or Modify: `crates/mt-core/src/docs/test_support.md` +- Test: `crates/mt-core/tests/git_boundary.rs` + +**Interfaces:** +- Produces: `TestRepo::new()` creating bare remote, work repo, initial commit and origin entirely through gix. +- Removes: test-only generic `git(dir, args)` helpers. + +- [x] **Step 1: Add the global static guard after every caller has migrated** + +```rust +#[test] +fn rust_tests_have_no_direct_git_command() { + assert_no_forbidden_git_command("crates"); +} +``` + +- [x] **Step 2: Run and confirm current failures** + +Run: `cargo test -p mt-core --test git_boundary` + +Expected: FAIL, listing fixture helpers and direct test shell-outs. + +- [x] **Step 3: Replace fixture setup and assertions** + +Create bare repos, initial commits, remotes, branches, object reads and ref assertions through `TestRepo` facade methods. Tests that need unsupported behavior invoke public production `compat` behavior and assert results through gix; they never execute system Git directly. + +- [x] **Step 4: Run workspace tests** + +Run: `cargo test --workspace` + +Expected: PASS with no Rust test invoking `Command::new("git")`. + +- [x] **Step 5: Docs, gate and commit** + +Run: `npx @7n/rules lint doc-files && npx @7n/rules lint changelog` + +Commit: `git add crates && git commit -m "test(git): build fixtures through gix"` + +### Task 8: Enforce the end state and publish migration evidence + +**Files:** +- Modify: `crates/mt-core/tests/git_boundary.rs` +- Modify: `docs/superpowers/specs/2026-07-29-gix-migration-design.md` + +**Interfaces:** +- Produces: permanent capability-matrix test and updated design status with exact compat allow-list. + +- [x] **Step 1: Write final enforcement tests** + +```rust +#[test] +fn compat_is_the_only_git_cli_boundary() { /* source scan */ } +#[test] +fn all_native_capabilities_have_gix_contract_coverage() { /* matrix rows */ } +``` + +- [x] **Step 2: Run enforcement tests and verify initial missing coverage** + +Run: `cargo test -p mt-core --test git_boundary` + +Expected: PASS only after Tasks 1-7; any newly added direct CLI call fails with its source path. + +- [ ] **Step 3: Update documentation** + +Mark the design's capability matrix with the pinned `gix` version, native APIs used and the three retained compat groups. Add behavior documentation for every changed Rust module through `npx @7n/rules lint doc-files`. + +- [ ] **Step 4: Run complete verification** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --workspace +npx @7n/rules lint +npx @7n/rules lint changelog +``` + +Expected: every command exits 0. + +- [ ] **Step 5: Commit final migration** + +```bash +git add crates docs +git commit -m "docs: record gix migration guarantees" +``` + +## Plan self-review + +- Spec coverage: Tasks 1-8 cover native facade, typed refs, claims, run refs, status/commits, worktrees, fenced publish, all test helpers, static enforcement and documentation. +- Placeholder scan: the plan has no deferred implementation markers; every task identifies files, APIs, a failing test, a command and a commit. +- Type consistency: `GitRepository`, typed refs and `RemoteUpdateResult` originate in Task 1 and are consumed only after that task; `compat` has the same five-method allow-list in the design and in Task 6. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-29-gix-migration.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — execute tasks in this session using `executing-plans`, in batches with checkpoints. + +Which approach? diff --git a/docs/superpowers/specs/2026-07-29-gix-migration-design.md b/docs/superpowers/specs/2026-07-29-gix-migration-design.md new file mode 100644 index 0000000..2653bac --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-gix-migration-design.md @@ -0,0 +1,142 @@ +# Gix migration design + +## Goal + +Перевести всі Git-взаємодії `mt-rust`, для яких `gix` має еквівалентну й +достатньо безпечну семантику, із запуску системного `git` на Rust API `gix`. +Зберегти поточні гарантії claims, recovery, worktree isolation і fenced +publish. Системний Git допускається лише за одним ізольованим інтерфейсом для +операцій, які `gix` на момент реалізації не підтримує на потрібному рівні. + +## Контекст + +Зараз production-код викликає `git` через `std::process::Command` у +`mt-core::{claims,worktree,publish,runner}` і частково в CLI. Git зберігає +стан розподіленого виконання у remote refs: + +- `refs/mt/claims/` — CAS-claim, commit містить `.mt-claim.yml`; +- `refs/mt/runs//` — recovery/handoff checkpoint; +- `refs/heads/main` — результат fenced publish. + +`gix` 0.86 надає repository discovery, refs, object database, commit/tree +creation, fetch transport і читання linked-worktree metadata. У pinned версії +немає достатнього public high-level API, щоб відтворити `git add -A` та +index-only removal з тією ж семантикою. Також він не надає готового API з +exact remote lease для custom refs, створення/видалення/prune linked worktrees, +повного rebase або atomic multi-ref push. + +## Рішення + +Ввести `mt-core::git` як єдину facade над Git. Код поза цим модулем не має +імпортувати `gix` і не має запускати `Command::new("git")`. Facade поділяється +за можливостями, а не за нинішніми файлами: + +```text +mt-core::git +├── Repository discovery, origin, refs, objects, claim commits +├── Remote list refs, fetch +├── Worktree inspection через gix +└── compat staging/commit, custom-ref CAS, worktree lifecycle, + branch config, rebase, atomic push і локальний ff +``` + +`compat` є внутрішнім, забороненим для прямого використання production-кодом. +Усі його методи мають назви за семантикою (`commit_all_if_changed`, +`push_with_expected`, `worktree`, `rebase_onto`, `push_atomic`) і +задокументовану причину fallback. Це не узагальнений "run arbitrary git" API. + +## Capability matrix + +| Поточна можливість | Цільовий механізм | Вимога до семантики | +| --- | --- | --- | +| Discover repo, root, `origin` | `gix::discover`, `Repository` config/remote | Працює з main і linked worktree. | +| Read/list local refs, resolve SHA | `gix` refs/object API | Повні ref names; без shell parsing. | +| Read remote claim refs | `gix` remote listing/fetch | Отримує тільки `refs/mt/claims/*`; errors не маскуються як empty state. | +| Create claim commit | `gix` object writer + ref transaction | Створює blob/tree/commit без index або checkout. | +| Claim acquire/renew/release | `compat` custom-ref CAS | Exact `--force-with-lease`; lease mismatch лишається доменним `false`. | +| Run ref checkpoint/delete | `compat` custom-ref push/CAS | Пушить/видаляє exact custom ref. | +| Commit worktree result | `compat` staging/commit | Зберігає author/committer `mt-runner`; no-op на чистому дереві. | +| Worktree list/inventory | `gix` linked-worktree inspection | Парсинг porcelain output зникає. | +| Worktree create/remove/prune | `compat` | Зберігає Git адміністративні записи й `--force`. | +| Rebase result worktree | `compat` | Конфлікт завершує publish, rebase abort виконується. | +| Fenced atomic publish | `compat` | Один atomic push: `main` update + CAS-delete claim/run refs. | +| Test fixture Git setup | `mt-core/test-support` + `gix` | Створює bare remote/worktree, читає refs/blobs і перевіряє remote advertisement без direct Git CLI. | +| CI/release shell commands | `gix` або non-Git tooling, де це runtime Rust | GitHub Actions checkout/push release workflow лишається GitHub Actions concern; не є Rust runtime. | + +## Безпека й коректність + +Claims і publish не можуть деградувати до read-then-write без remote CAS. +Якщо `gix` transport API не забезпечує exact expected-old-object для конкретної +операції, facade повертає capability error і викликає лише відповідний метод +`compat`; він не робить небезпечного best-effort push. + +Перед publish worktree rebase-иться на точно зафіксований актуальний +`origin/main`. Успіх означає лише результат атомарної операції, що разом +оновила `main` і видалила exact claim/run refs. Частковий publish є помилкою. + +Відкриття репозиторію використовує trust model `gix`; явна policy вирішує, чи +недовірений config є відмовою, а не непомітно запускає зовнішні helpers. + +## API межі + +`GitRepository::open(path)` повертає контекст для main або linked worktree. +Public methods приймають типізовані `FullRefName`, `ObjectId`, `ClaimRef` і +`RunRef`, а не сирі refspec strings. Конструювання `refs/mt/...` лишається в +одному модулі, який також валідовує node hash і token. + +Remote update повертає один із трьох результатів: + +- `Applied` — remote підтвердив очікувану зміну; +- `Rejected(LeaseMismatch)` — конкурентна гонка, нормальний доменний стан; +- `Err(GitError)` — transport, authentication, protocol або local I/O failure. + +Це прибирає залежність від парсингу англомовного `git push` stderr. + +## Міграційна послідовність + +1. Додати facade й тестове середовище `gix` без зміни поведінки callers. +2. Перевести discovery, refs, object/claim commit і remote claim read. +3. Для custom-ref CAS, status/index/commit залишити exact Git porcelain у + `compat`, доки gix не надає еквівалентну безпечну API. +4. Перевести worktree inspection, runtime fetch і SHA resolution на gix; + винести worktree lifecycle, rebase й atomic publish у `compat`. +5. Застосувати static guard: жоден Rust-файл у `crates/` не може містити + `Command::new("git")` поза `git::compat`. +6. Після кожного оновлення `gix` перевіряти matrix; реалізований upstream API + переносить відповідну capability з `compat` до native gix та видаляє fallback. + +## Тестування + +Unit-тести Git facade працюють із тимчасовими репозиторіями та локальним bare +remote, не з GitHub і не з checkout розробника. Обов'язкові сценарії: + +- acquire одночасно двома runners: рівно один `Applied`; +- renew/release зі старим SHA: `Rejected(LeaseMismatch)`; +- claim commit має рівно `.mt-claim.yml`, коректний parent і поля; +- run ref recovery доступний після нового відкриття repo; +- чистий worktree не створює commit, брудний створює його з поточною ідентичністю; +- linked worktree inventory коректно відрізняє main, branch і detached run; +- compat worktree/rebase/atomic publish мають окремі інтеграційні тести; +- race publish не оновлює `main` і не видаляє чужий claim; +- статичний test знаходить заборонений shell-out поза `git::compat`. + +Повний regression gate: `cargo test --workspace`, `cargo fmt --all -- --check`, +`cargo clippy --all-targets --all-features -- -D warnings`, репозиторний lint і +changelog gate. + +## Позамежі + +Міграція не змінює формат `.mt-claim.yml`, назви refs, протокол relay/ACP, +публічний CLI або логіку task graph. Вона також не замінює `actions/checkout` +у GitHub Actions: це environment provisioning, а не runtime Git-взаємодія +Rust-продукту. + +## Критерій завершення + +У Rust-коді нема прямого `Command::new("git")` поза `git::compat`. Усі можливості, +для яких pinned `gix` 0.86 має еквівалентний безпечний API, реалізовані native +API. Єдиний production shell-out живе в `mt-core::git::compat`; його allow-list +охоплює staging/commit, custom-ref CAS, worktree lifecycle, branch config, +rebase, atomic multi-ref push і локальний fast-forward. Test fixtures доступні +лише за feature `mt-core/test-support` і використовують gix facade та +семантичні compat операції, не generic shell-out. diff --git a/npm/mt-napi/package.json b/npm/mt-napi/package.json index 19de8d8..85dfc14 100644 --- a/npm/mt-napi/package.json +++ b/npm/mt-napi/package.json @@ -11,7 +11,8 @@ }, "files": [ "index.mjs", - "native.mjs" + "native.mjs", + "CHANGELOG.md" ], "optionalDependencies": { "@7n/mt-darwin-arm64": "0.1.1", diff --git a/packages/mt-darwin-arm64/package.json b/packages/mt-darwin-arm64/package.json index 07f84df..b8e7463 100644 --- a/packages/mt-darwin-arm64/package.json +++ b/packages/mt-darwin-arm64/package.json @@ -8,7 +8,8 @@ "url": "git+https://github.com/nitra/mt-rust.git" }, "files": [ - "mt.darwin-arm64.node" + "mt.darwin-arm64.node", + "CHANGELOG.md" ], "os": [ "darwin" diff --git a/packages/mt-linux-x64/package.json b/packages/mt-linux-x64/package.json index 141d3f5..78cb2be 100644 --- a/packages/mt-linux-x64/package.json +++ b/packages/mt-linux-x64/package.json @@ -8,7 +8,8 @@ "url": "git+https://github.com/nitra/mt-rust.git" }, "files": [ - "mt.linux-x64-musl.node" + "mt.linux-x64-musl.node", + "CHANGELOG.md" ], "os": [ "linux"