diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a10b2a1..98455b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -243,7 +243,7 @@ jobs: run: bash npm/scripts/publish.sh "${{ steps.version.outputs.value }}" publish-wasm: - name: Publish @ferrflow/wasm + name: Publish @ferrlabs/ferrflow-wasm needs: upload runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index b630d7b..5a3a0fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,40 +118,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -230,8 +202,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -241,12 +211,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.44" @@ -346,15 +310,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.2" @@ -376,16 +331,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "const-oid" version = "0.10.2" @@ -421,16 +366,6 @@ dependencies = [ "url", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -687,7 +622,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ferrflow" -version = "4.10.8" +version = "5.0.0" dependencies = [ "anyhow", "cargo-husky", @@ -696,8 +631,8 @@ dependencies = [ "clap_complete", "colored", "criterion", - "git2", "gix", + "gix-traverse", "glob-match", "hex", "hmac", @@ -775,61 +710,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -847,24 +727,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", ] [[package]] @@ -875,26 +739,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - [[package]] name = "gix" version = "0.83.0" @@ -903,12 +752,9 @@ checksum = "6ce52001b946a6249d5d0d3011df0a042ac3f8a4d013460db6476577b0b9c567" dependencies = [ "gix-actor", "gix-archive", - "gix-attributes", "gix-blame", - "gix-command", "gix-commitgraph", "gix-config", - "gix-credentials", "gix-date", "gix-diff", "gix-dir", @@ -920,7 +766,6 @@ dependencies = [ "gix-glob", "gix-hash", "gix-hashtable", - "gix-ignore", "gix-index", "gix-lock", "gix-merge", @@ -929,8 +774,6 @@ dependencies = [ "gix-odb", "gix-pack", "gix-path", - "gix-pathspec", - "gix-prompt", "gix-protocol", "gix-ref", "gix-refspec", @@ -942,7 +785,6 @@ dependencies = [ "gix-submodule", "gix-tempfile", "gix-trace", - "gix-transport", "gix-traverse", "gix-url", "gix-utils", @@ -1092,24 +934,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-credentials" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ca11598b70811d7b16ff90945a6e57dfe521e85b744e51636965fe39cc8f60" -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.3" @@ -1435,9 +1259,7 @@ dependencies = [ "gix-hashtable", "gix-object", "gix-path", - "gix-tempfile", "memmap2", - "parking_lot", "smallvec", "thiserror", "uluru", @@ -1482,19 +1304,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-prompt" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e041a626c64cb69e4117fcdf80da8d0e454fba3b1f420412792d191f52251aee" -dependencies = [ - "gix-command", - "gix-config-value", - "parking_lot", - "rustix", - "thiserror", -] - [[package]] name = "gix-protocol" version = "0.61.0" @@ -1502,18 +1311,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4bee82db63ec635996b96efae71cf467c155fa3f34a556184373224a26c4fd" dependencies = [ "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", "maybe-async", @@ -1691,16 +1493,13 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd6a5c676b92d4ead5f5a2b2935024415dec69edc997b6090ca9cac010a3018" dependencies = [ - "base64", "bstr", "gix-command", - "gix-credentials", "gix-features", "gix-packetline", "gix-quote", "gix-sec", "gix-url", - "reqwest", "thiserror", ] @@ -1813,25 +1612,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -1919,29 +1699,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - [[package]] name = "httparse" version = "1.10.1" @@ -1957,65 +1714,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2170,12 +1868,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2238,73 +1930,12 @@ dependencies = [ "jiff-tzdb", ] -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -2340,46 +1971,6 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2413,12 +2004,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "maybe-async" version = "0.2.11" @@ -2445,12 +2030,6 @@ dependencies = [ "libc", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2461,17 +2040,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "nonempty" version = "0.12.0" @@ -2511,40 +2079,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "page_size" version = "0.6.0" @@ -2584,18 +2118,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "plotters" version = "0.3.7" @@ -2654,15 +2176,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -2691,62 +2204,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -2756,47 +2213,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rayon" version = "1.11.0" @@ -2855,46 +2277,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "ring" version = "0.17.14" @@ -2909,21 +2291,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -2943,7 +2310,6 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -2953,62 +2319,21 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3029,44 +2354,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3176,44 +2469,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3249,15 +2510,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -3368,43 +2620,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3444,76 +2659,6 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.19.0" @@ -3630,12 +2775,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -3652,15 +2791,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3698,16 +2828,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.115" @@ -3784,25 +2904,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 1c8afd2..87390b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ workspace = true [features] default = ["cli"] -cli = ["dep:git2", "dep:gix", "dep:ureq", "dep:clap", "dep:clap_complete", "dep:colored", "dep:hmac"] +cli = ["dep:gix", "dep:gix-traverse", "dep:ureq", "dep:clap", "dep:clap_complete", "dep:colored", "dep:hmac"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -51,8 +51,8 @@ glob-match = "0.2" clap = { version = "4", features = ["derive", "env"], optional = true } clap_complete = { version = "4", optional = true } colored = { version = "3", optional = true } -git2 = { version = "0.20", features = ["vendored-libgit2", "vendored-openssl"], optional = true } -gix = { version = "0.83", default-features = false, features = ["sha1", "max-performance-safe", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "revision", "worktree-mutation"], optional = true } +gix = { version = "0.83", default-features = false, features = ["sha1", "max-performance-safe", "revision"], optional = true } +gix-traverse = { version = "0.57", optional = true } ureq = { version = "3", features = ["json"], optional = true } sha2 = "0.11.0" hex = "0.4.3" diff --git a/README.md b/README.md index 9ecd30a..cc9e7a0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Latest release](https://img.shields.io/github/v/release/FerrLabs/FerrFlow)](https://github.com/FerrLabs/FerrFlow/releases/latest) [![Coverage](https://codecov.io/gh/FerrLabs/FerrFlow/graph/badge.svg)](https://codecov.io/gh/FerrLabs/FerrFlow) [![License](https://img.shields.io/github/license/FerrLabs/FerrFlow)](LICENSE) -[![Socket Badge](https://badge.socket.dev/npm/package/ferrflow/latest)](https://badge.socket.dev/npm/package/ferrflow/latest) +[![Socket Badge](https://badge.socket.dev/npm/package/@ferrlabs/ferrflow/latest)](https://badge.socket.dev/npm/package/@ferrlabs/ferrflow/latest) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/FerrLabs/FerrFlow/badge)](https://scorecard.dev/viewer/?uri=github.com/FerrLabs/FerrFlow) Universal semantic versioning for monorepos and classic repos. @@ -44,7 +44,7 @@ cargo install ferrflow **npm** ```bash -npm install -D ferrflow +npm install -D @ferrlabs/ferrflow ``` **Docker** diff --git a/benches/ferrflow_benchmarks.rs b/benches/ferrflow_benchmarks.rs index 4a525b5..4ea9db0 100644 --- a/benches/ferrflow_benchmarks.rs +++ b/benches/ferrflow_benchmarks.rs @@ -177,56 +177,100 @@ fn bench_config_loading(c: &mut Criterion) { /// Create a git repo with `num_commits` commits and a tag at `tag_at` position. /// Returns the TempDir (must be kept alive) and the opened Repository. -fn create_bench_repo(num_commits: usize, tag_at: usize) -> (TempDir, git2::Repository) { - let dir = TempDir::new().unwrap(); - let repo = git2::Repository::init(dir.path()).unwrap(); +fn run_git(dir: &std::path::Path, args: &[&str]) -> String { + run_git_with_stdin(dir, args, None) +} + +fn run_git_with_stdin(dir: &std::path::Path, args: &[&str], stdin: Option<&[u8]>) -> String { + use std::io::Write; + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(dir).args(args); + cmd.env("GIT_AUTHOR_NAME", "bench"); + cmd.env("GIT_AUTHOR_EMAIL", "bench@test.com"); + cmd.env("GIT_AUTHOR_DATE", "1700000000 +0000"); + cmd.env("GIT_COMMITTER_NAME", "bench"); + cmd.env("GIT_COMMITTER_EMAIL", "bench@test.com"); + cmd.env("GIT_COMMITTER_DATE", "1700000000 +0000"); + if stdin.is_some() { + cmd.stdin(std::process::Stdio::piped()); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + let mut child = cmd + .spawn() + .unwrap_or_else(|e| panic!("git {} failed to spawn: {}", args.join(" "), e)); + if let Some(data) = stdin { + child + .stdin + .as_mut() + .unwrap() + .write_all(data) + .expect("write stdin"); + } + let out = child + .wait_with_output() + .unwrap_or_else(|e| panic!("git {} wait failed: {}", args.join(" "), e)); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + panic!("git {} failed: {}{}", args.join(" "), stdout, stderr); + } + String::from_utf8_lossy(&out.stdout).into_owned() +} - // Configure committer identity - let mut config = repo.config().unwrap(); - config.set_str("user.name", "bench").unwrap(); - config.set_str("user.email", "bench@test.com").unwrap(); +fn create_bench_repo(num_commits: usize, tag_at: usize) -> (TempDir, gix::Repository) { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + run_git(path, &["init", "-b", "main"]); + run_git(path, &["config", "user.name", "bench"]); + run_git(path, &["config", "user.email", "bench@test.com"]); + run_git(path, &["config", "commit.gpgsign", "false"]); - let sig = git2::Signature::now("bench", "bench@test.com").unwrap(); let types = ["feat", "fix", "refactor", "perf", "chore"]; let scopes = ["api", "auth", "db"]; - let mut parent_oid: Option = None; - + let mut parent: Option = None; for i in 0..num_commits { let t = types[i % types.len()]; let s = scopes[i % scopes.len()]; let breaking = if i % 20 == 0 && i > 0 { "!" } else { "" }; let msg = format!("{t}({s}){breaking}: change {i}"); - // Create a file change per commit let file_name = format!("src/file_{i}.rs"); - let file_path = dir.path().join(&file_name); - std::fs::create_dir_all(file_path.parent().unwrap()).unwrap(); - std::fs::write(&file_path, format!("// commit {i}\n")).unwrap(); - - let mut index = repo.index().unwrap(); - index.add_path(std::path::Path::new(&file_name)).unwrap(); - index.write().unwrap(); - let tree_oid = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_oid).unwrap(); - - let oid = if let Some(parent) = parent_oid { - let parent_commit = repo.find_commit(parent).unwrap(); - repo.commit(Some("HEAD"), &sig, &sig, &msg, &tree, &[&parent_commit]) - .unwrap() - } else { - repo.commit(Some("HEAD"), &sig, &sig, &msg, &tree, &[]) - .unwrap() - }; + let content = format!("// commit {i}\n"); + let blob_sha = run_git_with_stdin( + path, + &["hash-object", "-w", "--stdin"], + Some(content.as_bytes()), + ) + .trim() + .to_string(); + run_git( + path, + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("100644,{blob_sha},{file_name}"), + ], + ); + let tree_sha = run_git(path, &["write-tree"]).trim().to_string(); + let mut commit_args: Vec = vec!["commit-tree".into(), tree_sha, "-m".into(), msg]; + if let Some(p) = &parent { + commit_args.push("-p".into()); + commit_args.push(p.clone()); + } + let arg_refs: Vec<&str> = commit_args.iter().map(String::as_str).collect(); + let commit_sha = run_git(path, &arg_refs).trim().to_string(); + run_git(path, &["update-ref", "refs/heads/main", &commit_sha]); + parent = Some(commit_sha.clone()); if i == tag_at { - let obj = repo.find_object(oid, None).unwrap(); - repo.tag_lightweight("v1.0.0", &obj, false).unwrap(); + run_git(path, &["tag", "v1.0.0", &commit_sha]); } - - parent_oid = Some(oid); } + let repo = gix::discover(path).unwrap(); (dir, repo) } diff --git a/ferrflow-wasm/Cargo.toml b/ferrflow-wasm/Cargo.toml index 8f6cad7..4595189 100644 --- a/ferrflow-wasm/Cargo.toml +++ b/ferrflow-wasm/Cargo.toml @@ -13,3 +13,6 @@ ferrflow = { path = "..", default-features = false } wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/npm/bin/ferrflow.js b/npm/bin/ferrflow.js index 59e7e53..e374d1a 100644 --- a/npm/bin/ferrflow.js +++ b/npm/bin/ferrflow.js @@ -9,11 +9,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const PLATFORMS = { - "linux-x64": "@ferrflow/linux-x64", - "linux-arm64": "@ferrflow/linux-arm64", - "darwin-x64": "@ferrflow/darwin-x64", - "darwin-arm64": "@ferrflow/darwin-arm64", - "win32-x64": "@ferrflow/win32-x64", + "linux-x64": "@ferrlabs/ferrflow-linux-x64", + "linux-arm64": "@ferrlabs/ferrflow-linux-arm64", + "darwin-x64": "@ferrlabs/ferrflow-darwin-x64", + "darwin-arm64": "@ferrlabs/ferrflow-darwin-arm64", + "win32-x64": "@ferrlabs/ferrflow-win32-x64", }; function getBinaryPath() { diff --git a/npm/package.json b/npm/package.json index b14fc3d..f54b920 100644 --- a/npm/package.json +++ b/npm/package.json @@ -12,13 +12,13 @@ "release" ], "license": "MPL-2.0", - "name": "ferrflow", + "name": "@ferrlabs/ferrflow", "optionalDependencies": { - "@ferrflow/darwin-arm64": "5.0.0", - "@ferrflow/darwin-x64": "5.0.0", - "@ferrflow/linux-arm64": "5.0.0", - "@ferrflow/linux-x64": "5.0.0", - "@ferrflow/win32-x64": "5.0.0" + "@ferrlabs/ferrflow-darwin-arm64": "5.0.0", + "@ferrlabs/ferrflow-darwin-x64": "5.0.0", + "@ferrlabs/ferrflow-linux-arm64": "5.0.0", + "@ferrlabs/ferrflow-linux-x64": "5.0.0", + "@ferrlabs/ferrflow-win32-x64": "5.0.0" }, "repository": { "type": "git", diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json index 655c922..99bc28c 100644 --- a/npm/platforms/darwin-arm64/package.json +++ b/npm/platforms/darwin-arm64/package.json @@ -4,7 +4,7 @@ ], "description": "FerrFlow macOS arm64 binary", "license": "MPL-2.0", - "name": "@ferrflow/darwin-arm64", + "name": "@ferrlabs/ferrflow-darwin-arm64", "os": [ "darwin" ], diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json index 129a2f8..5b5a917 100644 --- a/npm/platforms/darwin-x64/package.json +++ b/npm/platforms/darwin-x64/package.json @@ -4,7 +4,7 @@ ], "description": "FerrFlow macOS x64 binary", "license": "MPL-2.0", - "name": "@ferrflow/darwin-x64", + "name": "@ferrlabs/ferrflow-darwin-x64", "os": [ "darwin" ], diff --git a/npm/platforms/linux-arm64/package.json b/npm/platforms/linux-arm64/package.json index 5af8d56..f87c60f 100644 --- a/npm/platforms/linux-arm64/package.json +++ b/npm/platforms/linux-arm64/package.json @@ -4,7 +4,7 @@ ], "description": "FerrFlow Linux arm64 binary", "license": "MPL-2.0", - "name": "@ferrflow/linux-arm64", + "name": "@ferrlabs/ferrflow-linux-arm64", "os": [ "linux" ], diff --git a/npm/platforms/linux-x64/package.json b/npm/platforms/linux-x64/package.json index 49093b2..7d315e0 100644 --- a/npm/platforms/linux-x64/package.json +++ b/npm/platforms/linux-x64/package.json @@ -4,7 +4,7 @@ ], "description": "FerrFlow Linux x64 binary", "license": "MPL-2.0", - "name": "@ferrflow/linux-x64", + "name": "@ferrlabs/ferrflow-linux-x64", "os": [ "linux" ], diff --git a/npm/platforms/win32-x64/package.json b/npm/platforms/win32-x64/package.json index c5212d7..a36caaf 100644 --- a/npm/platforms/win32-x64/package.json +++ b/npm/platforms/win32-x64/package.json @@ -4,7 +4,7 @@ ], "description": "FerrFlow Windows x64 binary", "license": "MPL-2.0", - "name": "@ferrflow/win32-x64", + "name": "@ferrlabs/ferrflow-win32-x64", "os": [ "win32" ], diff --git a/npm/scripts/publish-wasm.sh b/npm/scripts/publish-wasm.sh index 8e36801..46af9b3 100755 --- a/npm/scripts/publish-wasm.sh +++ b/npm/scripts/publish-wasm.sh @@ -7,18 +7,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" WASM_DIR="${REPO_DIR}/ferrflow-wasm" -echo "Building @ferrflow/wasm@${VERSION}..." +echo "Building @ferrlabs/ferrflow-wasm@${VERSION}..." cd "$WASM_DIR" -wasm-pack build --target bundler --scope ferrflow +wasm-pack build --target bundler --scope ferrlabs cd pkg -# Rename package from @ferrflow/ferrflow-wasm to @ferrflow/wasm node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.name = '@ferrflow/wasm'; + pkg.name = '@ferrlabs/ferrflow-wasm'; pkg.version = '${VERSION}'; pkg.repository = { type: 'git', @@ -29,7 +28,7 @@ node -e " fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); " -echo "Publishing @ferrflow/wasm@${VERSION}..." +echo "Publishing @ferrlabs/ferrflow-wasm@${VERSION}..." npm publish --access public -echo "Published @ferrflow/wasm@${VERSION}" +echo "Published @ferrlabs/ferrflow-wasm@${VERSION}" diff --git a/npm/scripts/publish.sh b/npm/scripts/publish.sh index 32abee9..a9486bd 100755 --- a/npm/scripts/publish.sh +++ b/npm/scripts/publish.sh @@ -20,7 +20,7 @@ echo "Downloading release binaries for v${VERSION}..." for archive in "${!ARCHIVES[@]}"; do platform="${ARCHIVES[$archive]}" - echo " ${archive} -> @ferrflow/${platform}" + echo " ${archive} -> @ferrlabs/ferrflow-${platform}" gh release download "v${VERSION}" -p "$archive" -D "$WORK_DIR" diff --git a/src/config/loader_js.rs b/src/config/loader_js.rs index 503b34b..e99503d 100644 --- a/src/config/loader_js.rs +++ b/src/config/loader_js.rs @@ -95,12 +95,14 @@ pub(crate) fn load_js_ts_config(path: &Path) -> Result { let result = Command::new("tsx") .arg(&wrapper_path) + .current_dir(wrapper_dir) .output() .or_else(|e| { if e.kind() == std::io::ErrorKind::NotFound { Command::new("npx") .args(["tsx"]) .arg(&wrapper_path) + .current_dir(wrapper_dir) .output() } else { Err(e) @@ -124,8 +126,10 @@ pub(crate) fn load_js_ts_config(path: &Path) -> Result { // .js — use node with inline script let script = loader_body(&file_url, "node"); + let parent = path.parent().unwrap_or(Path::new(".")); Command::new("node") .args(["--input-type=module", "-e", &script]) + .current_dir(parent) .output() .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { diff --git a/src/config/tests.rs b/src/config/tests.rs index 5d0b0ea..a3dfd60 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1400,7 +1400,7 @@ export default config;"#, .unwrap(); let config = match Config::load_explicit(&path) { Ok(c) => c, - Err(e) => panic!("load_explicit failed: {e}"), + Err(e) => panic!("load_explicit failed: {e:?}"), }; assert!( !config.packages.is_empty(), diff --git a/src/config/workspace.rs b/src/config/workspace.rs index c84cd72..7eb5925 100644 --- a/src/config/workspace.rs +++ b/src/config/workspace.rs @@ -63,12 +63,15 @@ fn default_branch() -> String { #[cfg(feature = "cli")] { let detected = (|| { - let repo = git2::Repository::discover(".").ok()?; + let repo = gix::discover(".").ok()?; let reference = repo.find_reference("refs/remotes/origin/HEAD").ok()?; - let target = reference.symbolic_target().map(String::from)?; - let branch = target - .strip_prefix("refs/remotes/origin/") - .unwrap_or(&target); + let target = reference.target(); + let target_name = match target { + gix::refs::TargetRef::Symbolic(name) => name, + _ => return None, + }; + let full = target_name.as_bstr().to_string(); + let branch = full.strip_prefix("refs/remotes/origin/").unwrap_or(&full); if branch.is_empty() { None } else { diff --git a/src/git/auth.rs b/src/git/auth.rs index bc31977..e3025be 100644 --- a/src/git/auth.rs +++ b/src/git/auth.rs @@ -1,9 +1,8 @@ -use anyhow::{Context, Result}; -use git2::{Cred, CredentialType, Repository}; use std::process::Command; -use crate::error_code::{self, ErrorCodeExt}; +use super::repo::Repository; +#[cfg(test)] pub(super) fn extract_url_password(url: &str) -> Option<(String, String)> { let after_scheme = url.split("://").nth(1)?; let userinfo = after_scheme.split('@').next()?; @@ -33,34 +32,6 @@ pub(super) fn token_for_url(url: &str) -> Option<(String, String)> { None } -pub(super) fn credentials_callback( - url: &str, - username_from_url: Option<&str>, - allowed_types: CredentialType, -) -> std::result::Result { - if allowed_types.contains(CredentialType::SSH_KEY) { - return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); - } - if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) { - if let Some((user, token)) = token_for_url(url) { - return Cred::userpass_plaintext(&user, &token); - } - if let Some((user, password)) = extract_url_password(url) { - return Cred::userpass_plaintext(&user, &password); - } - if let Ok(cfg) = git2::Config::open_default() - && let Ok(cred) = Cred::credential_helper(&cfg, url, username_from_url) - { - return Ok(cred); - } - eprintln!( - "Warning: No git credentials found. Set FERRFLOW_TOKEN (or GITHUB_TOKEN/GITLAB_TOKEN), \ - configure a git credential helper, or embed credentials in the remote URL." - ); - } - Cred::default() -} - pub(super) fn configure_git_command(cmd: &mut Command, url: &str) { if let Some((user, token)) = token_for_url(url) { let escaped_user = shell_escape(&user); @@ -77,13 +48,10 @@ fn shell_escape(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } -pub(super) fn get_remote<'a>(repo: &'a Repository, remote_name: &str) -> Result> { - repo.find_remote(remote_name) - .with_context(|| format!("Remote '{}' not found", remote_name)) - .error_code(error_code::GIT_REMOTE_NOT_FOUND) -} - pub fn get_remote_url(repo: &Repository, remote_name: &str) -> Option { let remote = repo.find_remote(remote_name).ok()?; - Some(remote.url()?.to_string()) + let url = remote + .url(gix::remote::Direction::Push) + .or_else(|| remote.url(gix::remote::Direction::Fetch))?; + Some(url.to_bstring().to_string()) } diff --git a/src/git/commits.rs b/src/git/commits.rs index 442dd21..2d9442b 100644 --- a/src/git/commits.rs +++ b/src/git/commits.rs @@ -1,10 +1,12 @@ -use anyhow::Result; -use git2::{Repository, Sort}; -use std::path::Path; +use anyhow::{Context, Result}; +use gix::ObjectId; +use gix::revision::walk::Sorting; pub use crate::changelog::GitLog; use crate::config::OrphanedTagStrategy; +use super::repo::Repository; +use super::shell::run_git; use super::tags::{find_last_stable_tag, find_last_tag_commit}; pub fn get_commits_since_last_tag( @@ -25,61 +27,51 @@ pub fn get_commits_since_last_stable_tag( get_commits_since_oid(repo, last_tag_oid) } -/// Walk commits from HEAD back to `last_tag_oid` (exclusive). Callers in -/// the multi-package monorepo loop resolve the OID once via `TagIndex` -/// and reuse this helper, sparing the per-package `tag_foreach` scan -/// that `find_last_tag_commit` would otherwise re-run. pub fn get_commits_since_oid( repo: &Repository, - last_tag_oid: Option, + last_tag_oid: Option, ) -> Result> { - let mut walk = repo.revwalk()?; - walk.push_head()?; - walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; + let head = repo.head_id()?.detach(); + let mut platform = repo.rev_walk([head]).sorting(Sorting::BreadthFirst); + if let Some(stop) = last_tag_oid { + platform = platform.with_hidden([stop]); + } + let walk = platform.all()?; let mut commits = Vec::new(); - for oid in walk { - let oid = oid?; - if let Some(stop) = last_tag_oid - && oid == stop - { - break; - } - if let Ok(commit) = repo.find_commit(oid) { - let message = commit.message().unwrap_or("").to_string(); - if message.contains("[skip ci]") { - continue; - } - commits.push(GitLog { - hash: oid.to_string()[..8].to_string(), - message, - }); + for info in walk { + let info = info?; + let commit = match repo.find_commit(info.id) { + Ok(c) => c, + Err(_) => continue, + }; + let raw = match commit.message_raw() { + Ok(m) => m, + Err(_) => continue, + }; + let message = String::from_utf8_lossy(raw).into_owned(); + if message.contains("[skip ci]") { + continue; } + commits.push(GitLog { + hash: info.id.to_string()[..8].to_string(), + message, + }); } Ok(commits) } -pub(super) fn signature(repo: &Repository) -> Result> { - if let Ok(sig) = repo.signature() { - return Ok(sig); - } - Ok(git2::Signature::now("FerrFlow", "contact@ferrflow.com")?) -} - pub fn create_commit(repo: &Repository, files: &[&str], message: &str) -> Result<()> { - let mut index = repo.index()?; - for file in files { - index.add_path(Path::new(file))?; - } - index.write()?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?; - let tree_id = index.write_tree()?; - let tree = repo.find_tree(tree_id)?; - let sig = signature(repo)?; - let parent = repo.head()?.peel_to_commit()?; + let mut add_args: Vec<&str> = vec!["add", "--"]; + add_args.extend_from_slice(files); + run_git(workdir, &add_args).with_context(|| "git add failed")?; - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?; + run_git(workdir, &["commit", "-m", message]).with_context(|| "git commit failed")?; Ok(()) } @@ -97,24 +89,60 @@ pub fn create_branch_and_commits( branch_name: &str, commits: &[(&[&str], &str)], ) -> Result<()> { - let head = repo.head()?.peel_to_commit()?; - repo.branch(branch_name, &head, false)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?; - let refname = format!("refs/heads/{branch_name}"); - let sig = signature(repo)?; - let mut parent = head; + let original_head = run_git(workdir, &["rev-parse", "HEAD"]) + .with_context(|| "rev-parse HEAD failed")? + .trim() + .to_string(); + + run_git(workdir, &["branch", branch_name]) + .with_context(|| format!("git branch {branch_name} failed"))?; for (files, message) in commits { - let mut index = repo.index()?; - for file in *files { - index.add_path(Path::new(file))?; - } - index.write()?; + let mut add_args: Vec<&str> = vec!["add", "--"]; + add_args.extend_from_slice(files); + run_git(workdir, &add_args).with_context(|| "git add failed")?; + + let tree_sha = run_git(workdir, &["write-tree"]) + .with_context(|| "git write-tree failed")? + .trim() + .to_string(); + let parent_sha = run_git( + workdir, + &["rev-parse", &format!("refs/heads/{branch_name}")], + ) + .with_context(|| format!("rev-parse {branch_name} failed"))? + .trim() + .to_string(); + let commit_sha = run_git( + workdir, + &["commit-tree", &tree_sha, "-p", &parent_sha, "-m", message], + ) + .with_context(|| "git commit-tree failed")? + .trim() + .to_string(); + run_git( + workdir, + &[ + "update-ref", + &format!("refs/heads/{branch_name}"), + &commit_sha, + ], + ) + .with_context(|| "git update-ref failed")?; + } - let tree_id = index.write_tree()?; - let tree = repo.find_tree(tree_id)?; - let oid = repo.commit(Some(&refname), &sig, &sig, message, &tree, &[&parent])?; - parent = repo.find_commit(oid)?; + let mut reset_args: Vec<&str> = vec!["reset", "--mixed", &original_head, "--"]; + for (files, _) in commits { + for f in *files { + reset_args.push(f); + } } + run_git(workdir, &reset_args).with_context(|| "git reset --mixed failed")?; + + let _ = repo; Ok(()) } diff --git a/src/git/diff.rs b/src/git/diff.rs index f9b2e13..d1912d2 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -1,45 +1,51 @@ use anyhow::Result; -use git2::Repository; +use gix::ObjectId; use crate::config::OrphanedTagStrategy; +use super::repo::Repository; +use super::shell::run_git; use super::tags::find_last_tag_commit; pub fn get_changed_files(repo: &Repository) -> Result> { - let head = match repo.head() { - Ok(h) => h.peel_to_commit()?, + let workdir = match repo.workdir() { + Some(p) => p, + None => return Ok(vec![]), + }; + let head = match repo.head_id() { + Ok(id) => id.detach(), Err(_) => return Ok(vec![]), }; - let head_tree = head.tree()?; - let files = if let Ok(parent) = head.parent(0) { - let parent_tree = parent.tree()?; - let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&head_tree), None)?; - let mut files = Vec::new(); - diff.foreach( - &mut |delta, _| { - if let Some(path) = delta.new_file().path() { - files.push(path.to_string_lossy().to_string()); - } - true - }, - None, - None, - None, - )?; - files + let has_parent = repo + .find_commit(head) + .ok() + .and_then(|c| c.parent_ids().next()) + .is_some(); + + let args: Vec = if has_parent { + vec![ + "diff-tree".into(), + "--no-commit-id".into(), + "--name-only".into(), + "-r".into(), + head.to_string(), + ] } else { - let mut files = Vec::new(); - head_tree.walk(git2::TreeWalkMode::PreOrder, |_, entry| { - if let Some(name) = entry.name() { - files.push(name.to_string()); - } - git2::TreeWalkResult::Ok - })?; - files + vec![ + "ls-tree".into(), + "-r".into(), + "--name-only".into(), + head.to_string(), + ] }; - - Ok(files) + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = run_git(workdir, &arg_refs)?; + Ok(out + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect()) } pub fn get_changed_files_since_tag( @@ -51,40 +57,39 @@ pub fn get_changed_files_since_tag( get_changed_files_since_oid(repo, last_tag_oid) } -/// Same as [`get_changed_files_since_tag`] but skips the tag lookup — -/// callers in the multi-package monorepo loop resolve the OID once via -/// `TagIndex` instead of paying for an independent `tag_foreach` per -/// package. pub fn get_changed_files_since_oid( repo: &Repository, - last_tag_oid: Option, + last_tag_oid: Option, ) -> Result> { - let head = match repo.head() { - Ok(h) => h.peel_to_commit()?, + let workdir = match repo.workdir() { + Some(p) => p, + None => return Ok(vec![]), + }; + let head = match repo.head_id() { + Ok(id) => id.detach(), Err(_) => return Ok(vec![]), }; - let head_tree = head.tree()?; - let old_tree = if let Some(tag_oid) = last_tag_oid { - let tag_commit = repo.find_commit(tag_oid)?; - Some(tag_commit.tree()?) - } else { - None + let head_str = head.to_string(); + let args: Vec = match last_tag_oid { + Some(tag_oid) => vec![ + "diff".into(), + "--name-only".into(), + tag_oid.to_string(), + head_str, + ], + None => vec![ + "ls-tree".into(), + "-r".into(), + "--name-only".into(), + head_str, + ], }; - - let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&head_tree), None)?; - let mut files = Vec::new(); - diff.foreach( - &mut |delta, _| { - if let Some(path) = delta.new_file().path() { - files.push(path.to_string_lossy().to_string()); - } - true - }, - None, - None, - None, - )?; - - Ok(files) + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = run_git(workdir, &arg_refs)?; + Ok(out + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect()) } diff --git a/src/git/fetch.rs b/src/git/fetch.rs index fb96e31..a4d9c04 100644 --- a/src/git/fetch.rs +++ b/src/git/fetch.rs @@ -1,19 +1,26 @@ -use anyhow::Result; -use git2::{RemoteCallbacks, Repository}; +use anyhow::{Context, Result, anyhow}; -use super::auth::{credentials_callback, get_remote}; - -pub(super) fn make_fetch_options() -> git2::FetchOptions<'static> { - let mut callbacks = RemoteCallbacks::new(); - callbacks.credentials(credentials_callback); - let mut opts = git2::FetchOptions::new(); - opts.remote_callbacks(callbacks); - opts -} +use super::auth::{configure_git_command, get_remote_url}; +use super::repo::Repository; pub fn fetch_tags(repo: &Repository, remote_name: &str) -> Result<()> { - let mut remote = get_remote(repo, remote_name)?; - let mut opts = make_fetch_options(); - remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut opts), None)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("Bare repositories are not supported"))?; + let url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' not found"))?; + + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(workdir); + configure_git_command(&mut cmd, &url); + cmd.args(["fetch", "--tags", remote_name]); + + let output = cmd + .output() + .with_context(|| "spawn `git fetch --tags` failed (is git in PATH?)")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("git fetch --tags failed: {}", stderr.trim())); + } Ok(()) } diff --git a/src/git/mod.rs b/src/git/mod.rs index cc7a292..0b730b5 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -5,6 +5,7 @@ mod fetch; mod push; mod repo; mod retry; +mod shell; mod tags; pub use auth::get_remote_url; @@ -19,7 +20,7 @@ pub use fetch::fetch_tags; pub use push::{ force_push_tags, push, push_branch, push_tags, reset_branch_to_remote, verify_remote_branch, }; -pub use repo::{get_repo_root, open_repo, resolve_current_branch}; +pub use repo::{Repository, get_repo_root, open_repo, resolve_current_branch}; pub use retry::is_push_rejected_error; pub use tags::{ TagIndex, build_head_ancestors, collect_all_tags, create_or_move_tag, create_tag, diff --git a/src/git/push.rs b/src/git/push.rs index fdac2a8..5e02352 100644 --- a/src/git/push.rs +++ b/src/git/push.rs @@ -1,24 +1,25 @@ -use anyhow::{Context, Result}; -use git2::{PushOptions, RemoteCallbacks, Repository, Sort}; -use std::cell::RefCell; +use anyhow::{Context, Result, anyhow}; +use gix::ObjectId; use std::collections::HashMap; use std::path::Path; -use std::rc::Rc; use crate::error_code::{self, ErrorCodeExt}; -use super::auth::{configure_git_command, credentials_callback, get_remote}; -use super::fetch::make_fetch_options; +use super::auth::{configure_git_command, get_remote_url}; +use super::repo::Repository; use super::retry::retry_transient; +use super::shell::run_git; fn local_tag_target_sha(repo: &Repository, tag: &str) -> Result { - let tag_ref = repo - .find_reference(&format!("refs/tags/{tag}")) - .with_context(|| format!("local tag '{tag}' not found"))?; - let commit = tag_ref - .peel_to_commit() - .with_context(|| format!("could not resolve tag '{tag}' to a commit"))?; - Ok(commit.id().to_string()) + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("Bare repositories are not supported"))?; + let out = run_git( + workdir, + &["rev-list", "-n", "1", &format!("refs/tags/{tag}")], + ) + .with_context(|| format!("could not resolve tag '{tag}' to a commit"))?; + Ok(out.trim().to_string()) } pub(super) fn parse_ls_remote_tags(stdout: &str) -> HashMap { @@ -64,10 +65,7 @@ pub(super) fn remote_tag_target_shas( .with_context(|| "spawn `git ls-remote --tags` failed (is git in PATH?)")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!( - "git ls-remote --tags failed: {}", - stderr.trim() - )); + return Err(anyhow!("git ls-remote --tags failed: {}", stderr.trim())); } Ok(parse_ls_remote_tags(&String::from_utf8_lossy( &output.stdout, @@ -87,65 +85,59 @@ fn try_force_push_tags_once(repo: &Repository, remote_name: &str, tags: &[&str]) shell_push_tags(repo, remote_name, tags, true).error_code(error_code::GIT_FLOATING_TAGS) } -fn make_push_options(push_errors: Rc>>) -> PushOptions<'static> { - let mut callbacks = RemoteCallbacks::new(); - callbacks.credentials(credentials_callback); - let errors = push_errors.clone(); - callbacks.push_update_reference(move |refname, status| { - if let Some(msg) = status { - errors.borrow_mut().push(format!("{refname}: {msg}")); - } - Ok(()) - }); - let mut push_options = PushOptions::new(); - push_options.remote_callbacks(callbacks); - push_options -} - -fn check_push_errors(errors: &RefCell>) -> Result<()> { - let errs = errors.borrow(); - if errs.is_empty() { - return Ok(()); - } - let joined = errs.join("; "); - Err(anyhow::anyhow!("Push rejected by remote: {joined}")) - .error_code(error_code::GIT_PUSH_REJECTED)?; - Ok(()) -} - pub fn verify_remote_branch( repo: &Repository, remote_name: &str, branch: &str, - expected_oid: git2::Oid, + expected_oid: ObjectId, ) -> Result<()> { - let mut remote = get_remote(repo, remote_name)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("Bare repositories are not supported"))?; + let url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; - let mut callbacks = RemoteCallbacks::new(); - callbacks.credentials(credentials_callback); + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(workdir); + configure_git_command(&mut cmd, &url); + cmd.args([ + "ls-remote", + "--heads", + &url, + &format!("refs/heads/{branch}"), + ]); - let connection = remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?; + let output = cmd + .output() + .with_context(|| "spawn `git ls-remote --heads` failed (is git in PATH?)")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("git ls-remote --heads failed: {}", stderr.trim())); + } let expected_ref = format!("refs/heads/{branch}"); - for head in connection.list()? { - if head.name() == expected_ref { - if head.oid() == expected_oid { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let Some((sha, refname)) = line.split_once('\t') else { + continue; + }; + if refname.trim() == expected_ref { + let actual = ObjectId::from_hex(sha.trim().as_bytes()) + .with_context(|| format!("invalid sha from remote: {sha}"))?; + if actual == expected_oid { return Ok(()); } - Err(anyhow::anyhow!( + Err(anyhow!( "Remote branch '{}' points to {} but expected {}", branch, - head.oid(), + actual, expected_oid, )) .error_code(error_code::GIT_PUSH_VERIFY_FAILED)?; } } - Err(anyhow::anyhow!( - "Remote branch '{}' not found after push", - branch - )) - .error_code(error_code::GIT_REMOTE_BRANCH_NOT_FOUND)?; + Err(anyhow!("Remote branch '{}' not found after push", branch)) + .error_code(error_code::GIT_REMOTE_BRANCH_NOT_FOUND)?; Ok(()) } @@ -176,14 +168,9 @@ fn try_push_tags_once(repo: &Repository, remote_name: &str, tags: &[&str]) -> Re fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: bool) -> Result<()> { let workdir = repo .workdir() - .ok_or_else(|| anyhow::anyhow!("bare repos are not supported"))?; - let remote = repo - .find_remote(remote_name) - .with_context(|| format!("Remote '{remote_name}' not found"))?; - let push_url = remote - .url() - .ok_or_else(|| anyhow::anyhow!("Remote '{remote_name}' has no URL"))? - .to_string(); + .ok_or_else(|| anyhow!("bare repos are not supported"))?; + let push_url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; let remote_shas = remote_tag_target_shas(workdir, &push_url, tags).unwrap_or_else(|err| { eprintln!( @@ -224,7 +211,7 @@ fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: b }) .collect::>() .join(", "); - return Err(anyhow::anyhow!( + return Err(anyhow!( "Tag(s) already exist on remote pointing to a different commit: {joined}. \ This usually means a previous release run partially succeeded — \ delete the divergent remote tag(s) and retry, or use --force if you really want to overwrite." @@ -256,7 +243,7 @@ fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: b } else { "Failed to push tags" }; - return Err(anyhow::anyhow!("{label}: {detail}")); + return Err(anyhow!("{label}: {detail}")); } Ok(()) } @@ -268,165 +255,190 @@ fn try_push_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result } fn try_push_branch_once(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = get_remote(repo, remote_name)?; - let push_errors = Rc::new(RefCell::new(Vec::new())); - let mut opts = make_push_options(push_errors.clone()); + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("bare repos are not supported"))?; + let push_url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; let source = resolve_push_source(repo, branch); - let branch_refspec = format!("{source}:refs/heads/{branch}"); - remote - .push(&[&branch_refspec], Some(&mut opts)) - .with_context(|| format!("Failed to push branch '{branch}'")) - .error_code(error_code::GIT_PUSH_BRANCH)?; - check_push_errors(&push_errors) - .with_context(|| format!("Branch push rejected for '{branch}'")) - .error_code(error_code::GIT_PUSH_REJECTED)?; + let refspec = format!("{source}:refs/heads/{branch}"); + + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(workdir); + configure_git_command(&mut cmd, &push_url); + cmd.arg("push").arg(&push_url).arg(&refspec); + + let output = cmd + .output() + .with_context(|| format!("spawn `git push` for branch '{branch}' failed"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = format!("{stdout}{stderr}").trim().to_string(); + return Err(anyhow!("Failed to push branch '{branch}': {detail}")) + .error_code(error_code::GIT_PUSH_BRANCH); + } Ok(()) } pub(super) fn fetch_and_rebase(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = get_remote(repo, remote_name)?; - let mut opts = make_fetch_options(); - remote.fetch( - &[&format!( - "refs/heads/{branch}:refs/remotes/{remote_name}/{branch}" - )], - Some(&mut opts), - None, - )?; - drop(remote); - - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_oid = repo - .refname_to_id(&remote_ref) - .with_context(|| format!("Could not find remote ref {remote_ref} after fetch"))?; - - let local_commit = repo.head()?.peel_to_commit()?; - let local_oid = local_commit.id(); - - if remote_oid == local_oid || repo.graph_descendant_of(local_oid, remote_oid)? { - return Ok(()); + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("bare repos are not supported"))?; + let push_url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; + + let mut fetch_cmd = std::process::Command::new("git"); + fetch_cmd.current_dir(workdir); + configure_git_command(&mut fetch_cmd, &push_url); + fetch_cmd.arg("fetch").arg(remote_name).arg(format!( + "+refs/heads/{branch}:refs/remotes/{remote_name}/{branch}" + )); + let fetch_out = fetch_cmd + .output() + .with_context(|| "spawn `git fetch` failed")?; + if !fetch_out.status.success() { + let stderr = String::from_utf8_lossy(&fetch_out.stderr); + return Err(anyhow!("git fetch failed: {}", stderr.trim())); } - let merge_base = repo - .merge_base(local_oid, remote_oid) - .with_context(|| "No common ancestor between local and remote branch")?; - - let mut local_commits = Vec::new(); - let mut walk = repo.revwalk()?; - walk.push(local_oid)?; - walk.hide(merge_base)?; - walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; - for oid in walk { - local_commits.push(oid?); - } + let remote_oid_str = run_git( + workdir, + &["rev-parse", &format!("refs/remotes/{remote_name}/{branch}")], + ) + .with_context(|| format!("could not resolve refs/remotes/{remote_name}/{branch}"))? + .trim() + .to_string(); + let local_oid_str = run_git(workdir, &["rev-parse", "HEAD"]) + .with_context(|| "could not resolve HEAD")? + .trim() + .to_string(); - if local_commits.is_empty() { + if remote_oid_str == local_oid_str { return Ok(()); } - let mut current_parent = repo.find_commit(remote_oid)?; - for commit_oid in &local_commits { - let commit = repo.find_commit(*commit_oid)?; - let commit_parent_tree = commit.parent(0)?.tree()?; - let commit_tree = commit.tree()?; - let new_base_tree = current_parent.tree()?; - - let mut merge_index = - repo.merge_trees(&commit_parent_tree, &new_base_tree, &commit_tree, None)?; - if merge_index.has_conflicts() { - let paths: Vec = merge_index - .conflicts() - .ok() - .into_iter() - .flatten() - .filter_map(|c| c.ok()) - .filter_map(|c| { - c.our - .as_ref() - .or(c.their.as_ref()) - .or(c.ancestor.as_ref()) - .map(|e| String::from_utf8_lossy(&e.path).into_owned()) - }) - .collect(); - let path_list = if paths.is_empty() { - String::new() - } else { - format!("\nConflicting paths:\n - {}", paths.join("\n - ")) - }; - anyhow::bail!( - "Rebase conflict: cannot rebase release commits on top of remote '{branch}'. \ - Run manually or use releaseCommitMode = \"pr\".{path_list}" - ); - } - - let new_tree_oid = merge_index.write_tree_to(repo)?; - let new_tree = repo.find_tree(new_tree_oid)?; - - let new_oid = repo.commit( - None, - &commit.author(), - &commit.committer(), - commit.message().unwrap_or(""), - &new_tree, - &[¤t_parent], - )?; - current_parent = repo.find_commit(new_oid)?; + if run_git( + workdir, + &[ + "merge-base", + "--is-ancestor", + &remote_oid_str, + &local_oid_str, + ], + ) + .is_ok() + { + return Ok(()); } let local_ref = format!("refs/heads/{branch}"); - if repo.find_reference(&local_ref).is_ok() { - repo.reference( - &local_ref, - current_parent.id(), - true, - "ferrflow: rebase on push", + let on_branch = run_git(workdir, &["symbolic-ref", "-q", "HEAD"]) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default() + == local_ref; + + if on_branch { + let rebase_result = run_git( + workdir, + &["rebase", &format!("refs/remotes/{remote_name}/{branch}")], + ); + if let Err(err) = rebase_result { + let _ = run_git(workdir, &["rebase", "--abort"]); + return Err(anyhow!( + "Rebase conflict: cannot rebase release commits on top of remote '{branch}'. \ + Run manually or use releaseCommitMode = \"pr\".\n{err}" + )); + } + } else { + let _ = run_git(workdir, &["update-ref", &local_ref, &remote_oid_str]); + let merge_base = run_git(workdir, &["merge-base", &local_oid_str, &remote_oid_str]) + .with_context(|| "No common ancestor between local and remote branch")? + .trim() + .to_string(); + + let revs_output = run_git( + workdir, + &[ + "rev-list", + "--reverse", + "--topo-order", + &format!("{merge_base}..{local_oid_str}"), + ], )?; + let revs: Vec<&str> = revs_output.lines().filter(|l| !l.is_empty()).collect(); + let mut current_parent = remote_oid_str.clone(); + for rev in revs { + let tree_sha = run_git(workdir, &["rev-parse", &format!("{rev}^{{tree}}")])? + .trim() + .to_string(); + let msg = run_git(workdir, &["log", "-1", "--format=%B", rev])?; + let new_sha = run_git( + workdir, + &[ + "commit-tree", + &tree_sha, + "-p", + ¤t_parent, + "-m", + msg.trim_end(), + ], + )? + .trim() + .to_string(); + current_parent = new_sha; + } + run_git(workdir, &["update-ref", &local_ref, ¤t_parent])?; + run_git(workdir, &["checkout", "--detach", ¤t_parent])?; + run_git(workdir, &["checkout", "-f", branch]).ok(); } - repo.set_head_detached(current_parent.id())?; - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; Ok(()) } pub fn reset_branch_to_remote(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = get_remote(repo, remote_name)?; - let mut opts = make_fetch_options(); - remote - .fetch( - &[&format!( - "refs/heads/{branch}:refs/remotes/{remote_name}/{branch}" - )], - Some(&mut opts), - None, - ) + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("bare repos are not supported"))?; + let push_url = get_remote_url(repo, remote_name) + .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; + + let mut fetch_cmd = std::process::Command::new("git"); + fetch_cmd.current_dir(workdir); + configure_git_command(&mut fetch_cmd, &push_url); + fetch_cmd.arg("fetch").arg(remote_name).arg(format!( + "+refs/heads/{branch}:refs/remotes/{remote_name}/{branch}" + )); + let fetch_out = fetch_cmd + .output() .with_context(|| format!("Failed to fetch '{remote_name}/{branch}' for reset"))?; - drop(remote); - - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_oid = repo - .refname_to_id(&remote_ref) - .with_context(|| format!("Could not find remote ref {remote_ref} after fetch"))?; - - let local_ref = format!("refs/heads/{branch}"); - if repo.find_reference(&local_ref).is_ok() { - repo.reference( - &local_ref, - remote_oid, - true, - "ferrflow: reset to remote for release retry", - )?; + if !fetch_out.status.success() { + let stderr = String::from_utf8_lossy(&fetch_out.stderr); + return Err(anyhow!( + "Failed to fetch '{remote_name}/{branch}' for reset: {}", + stderr.trim() + )); } - repo.set_head_detached(remote_oid)?; - repo.checkout_head(Some( - git2::build::CheckoutBuilder::new() - .force() - .remove_untracked(true), - ))?; + let remote_oid = run_git( + workdir, + &["rev-parse", &format!("refs/remotes/{remote_name}/{branch}")], + ) + .with_context(|| { + format!("Could not find remote ref refs/remotes/{remote_name}/{branch} after fetch") + })? + .trim() + .to_string(); + let local_ref = format!("refs/heads/{branch}"); if repo.find_reference(&local_ref).is_ok() { - repo.set_head(&local_ref)?; + run_git(workdir, &["update-ref", &local_ref, &remote_oid])?; + run_git(workdir, &["checkout", "-f", branch])?; + } else { + run_git(workdir, &["checkout", "-f", &remote_oid])?; } + run_git(workdir, &["clean", "-fd"]).ok(); Ok(()) } @@ -444,6 +456,7 @@ pub fn push(repo: &Repository, remote_name: &str, branch: &str, tags: &[&str]) - || msg.contains("not fast forward") || msg.contains("non-fast-forward") || msg.contains("push rejected") + || msg.contains("rejected") }); if !is_non_ff || attempt == MAX_PUSH_RETRIES { @@ -462,7 +475,12 @@ pub fn push(repo: &Repository, remote_name: &str, branch: &str, tags: &[&str]) - } } - let head_oid = repo.head()?.peel_to_commit()?.id(); + let workdir = repo + .workdir() + .ok_or_else(|| anyhow!("bare repos are not supported"))?; + let head_str = run_git(workdir, &["rev-parse", "HEAD"])?.trim().to_string(); + let head_oid = ObjectId::from_hex(head_str.as_bytes()) + .with_context(|| format!("invalid HEAD sha: {head_str}"))?; verify_remote_branch(repo, remote_name, branch, head_oid) .with_context(|| "Post-push verification failed: release commit not on remote branch") .error_code(error_code::GIT_PUSH_VERIFY_FAILED)?; diff --git a/src/git/repo.rs b/src/git/repo.rs index 4b30af2..a62fe6e 100644 --- a/src/git/repo.rs +++ b/src/git/repo.rs @@ -1,11 +1,12 @@ use anyhow::{Context, Result}; -use git2::Repository; use std::path::{Path, PathBuf}; use crate::error_code::{self, ErrorCodeExt}; +pub type Repository = gix::Repository; + pub fn open_repo(path: &Path) -> Result { - Repository::discover(path) + gix::discover(path) .with_context(|| format!("Not a git repository: {}", path.display())) .error_code(error_code::GIT_NOT_A_REPO) } @@ -19,10 +20,13 @@ pub fn get_repo_root(repo: &Repository) -> Result { pub fn resolve_current_branch(repo: &Repository, fallback: &str) -> String { if let Ok(head) = repo.head() - && head.is_branch() - && let Some(name) = head.shorthand() + && let Some(name) = head.referent_name() { - return name.to_string(); + let full = name.as_bstr().to_string(); + if let Some(short) = full.strip_prefix("refs/heads/") { + return short.to_string(); + } + return full; } let ci_vars = [ diff --git a/src/git/shell.rs b/src/git/shell.rs new file mode 100644 index 0000000..d9339ce --- /dev/null +++ b/src/git/shell.rs @@ -0,0 +1,30 @@ +use anyhow::{Context, Result, anyhow}; +use std::path::Path; +use std::process::Command; + +pub(super) fn run_git(workdir: &Path, args: &[&str]) -> Result { + run_git_with_env(workdir, args, &[]) +} + +pub(super) fn run_git_with_env( + workdir: &Path, + args: &[&str], + extra_env: &[(&str, &str)], +) -> Result { + let mut cmd = Command::new("git"); + cmd.current_dir(workdir); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.args(args); + let output = cmd + .output() + .with_context(|| format!("spawn `git {}` failed (is git in PATH?)", args.join(" ")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}").trim().to_string(); + return Err(anyhow!("git {} failed: {}", args.join(" "), combined)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/src/git/tags.rs b/src/git/tags.rs index 103c7d4..a30b133 100644 --- a/src/git/tags.rs +++ b/src/git/tags.rs @@ -1,101 +1,78 @@ -use anyhow::Result; -use git2::Repository; -use std::cell::RefCell; +use anyhow::{Context, Result}; +use gix::ObjectId; +use gix::revision::walk::Sorting; +use gix_traverse::commit::simple::CommitTimeOrder; use std::collections::HashSet; +use super::repo::Repository; +use super::shell::run_git; use crate::config::OrphanedTagStrategy; use crate::error_code::{self, ErrorCodeExt}; -use super::commits::signature; - -/// Build the set of commit OIDs reachable from HEAD via a single revwalk. -/// -/// On a monorepo with N packages, `find_last_tag` (and its siblings) is -/// called once per package. Each call used to invoke -/// `repo.graph_descendant_of(head, oid)` per matching tag, which itself -/// walks the commit graph until it hits the target. On dense histories -/// (mono-large: 200 pkgs × 10k commits), this turns into N independent -/// O(commits) walks — 1815 ms for `ferrflow tag` was almost all this. -/// -/// Building the ancestor set up front collapses that into one walk + -/// O(1) hash lookups per tag. Callers in the per-package loop pass -/// `Some(&set)`; single-shot callers (status, query, single-package -/// release) pass `None` and pay the original cost. -pub fn build_head_ancestors(repo: &Repository) -> Result> { - let head_oid = repo.head()?.peel_to_commit()?.id(); - let mut walk = repo.revwalk()?; - walk.push(head_oid)?; - let mut set: HashSet = HashSet::new(); - for oid in walk.flatten() { - set.insert(oid); +pub fn build_head_ancestors(repo: &Repository) -> Result> { + let head_id = repo.head_id()?.detach(); + let walk = repo + .rev_walk([head_id]) + .sorting(Sorting::BreadthFirst) + .all()?; + let mut set: HashSet = HashSet::new(); + for info in walk { + let info = info?; + set.insert(info.id); } Ok(set) } -/// Pre-collected tag index with HEAD reachability information. -/// -/// Built once at the start of a multi-package operation (monorepo -/// release, `ferrflow tag` listing, etc.), then queried per package. -/// Avoids both the per-tag `graph_descendant_of` walk (already addressed -/// by `build_head_ancestors`) AND the per-call `tag_foreach` scan that -/// was still dominating on dense histories: 200 packages × ~200 tags × -/// callback overhead ≈ several hundred ms on mono-large. -/// -/// The fast path covers `OrphanedTagStrategy::Warn` (default and most -/// common). Callers needing tree-hash or message recovery for orphan -/// tags fall back to the per-call `find_*_tag_with_cache` path which -/// still uses the ancestor set but doesn't benefit from the pre-scan. pub struct TagIndex { entries: Vec, - pub ancestors: HashSet, + pub ancestors: HashSet, } struct TagIndexEntry { name: String, - commit_oid: git2::Oid, + commit_oid: ObjectId, time: i64, reachable: bool, } impl TagIndex { pub fn build(repo: &Repository) -> Result { - let head = repo.head()?.peel_to_commit()?.id(); - let mut walk = repo.revwalk()?; - walk.push(head)?; - let mut ancestors: HashSet = HashSet::new(); - for oid in walk.flatten() { - ancestors.insert(oid); + let head_id = repo.head_id()?.detach(); + let walk = repo + .rev_walk([head_id]) + .sorting(Sorting::BreadthFirst) + .all()?; + let mut ancestors: HashSet = HashSet::new(); + for info in walk { + let info = info?; + ancestors.insert(info.id); } - let entries: RefCell> = RefCell::new(Vec::new()); - repo.tag_foreach(|oid, name| { - let name = String::from_utf8_lossy(name); - let tag_name = name.trim_start_matches("refs/tags/").to_string(); - let commit_oid = if let Ok(tag_obj) = repo.find_tag(oid) { - tag_obj.target_id() - } else { - oid + let mut entries: Vec = Vec::new(); + let references = repo.references()?; + for reference in references.tags()?.flatten() { + let tag_name = String::from_utf8_lossy(reference.name().shorten()).into_owned(); + let commit_oid = match resolve_tag_to_commit(repo, reference.id().detach()) { + Some(oid) => oid, + None => continue, }; - if let Ok(commit) = repo.find_commit(commit_oid) { - let reachable = head == commit_oid || ancestors.contains(&commit_oid); - entries.borrow_mut().push(TagIndexEntry { - name: tag_name, - commit_oid, - time: commit.time().seconds(), - reachable, - }); - } - true - })?; - Ok(Self { - entries: entries.into_inner(), - ancestors, - }) + let commit = match repo.find_commit(commit_oid) { + Ok(c) => c, + Err(_) => continue, + }; + let time = commit.time().map(|t| t.seconds).unwrap_or(0); + let reachable = head_id == commit_oid || ancestors.contains(&commit_oid); + entries.push(TagIndexEntry { + name: tag_name, + commit_oid, + time, + reachable, + }); + } + + Ok(Self { entries, ancestors }) } - /// Fast-path version of `find_last_tag_name` for the Warn strategy. - /// Returns None when the caller asked for orphan recovery — caller - /// should fall back to `find_last_tag_name_with_cache` in that case. pub fn find_last_tag_name( &self, prefix: &str, @@ -113,7 +90,6 @@ impl TagIndex { .map(|e| e.name.clone()) } - /// Fast-path version of `find_highest_semver_tag` for the Warn strategy. pub fn find_highest_semver_tag( &self, prefix: &str, @@ -148,12 +124,11 @@ impl TagIndex { best.map(|(name, version)| (name.to_string(), version.to_string())) } - /// Fast-path lookup of the commit OID of the most recent matching tag. pub fn find_last_tag_commit( &self, prefix: &str, strategy: OrphanedTagStrategy, - ) -> Option { + ) -> Option { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } @@ -166,12 +141,11 @@ impl TagIndex { .map(|e| e.commit_oid) } - /// Fast-path lookup of the most recent stable (non-prerelease) tag's commit. pub fn find_last_stable_tag_commit( &self, prefix: &str, strategy: OrphanedTagStrategy, - ) -> Option { + ) -> Option { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } @@ -188,55 +162,104 @@ impl TagIndex { } } +fn resolve_tag_to_commit(repo: &Repository, oid: ObjectId) -> Option { + let object = repo.find_object(oid).ok()?; + match object.kind { + gix::object::Kind::Commit => Some(oid), + gix::object::Kind::Tag => { + let tag = object.try_into_tag().ok()?; + let decoded = tag.decode().ok()?; + let target_id: ObjectId = decoded.target(); + if matches!(decoded.target_kind, gix::object::Kind::Commit) { + Some(target_id) + } else { + resolve_tag_to_commit(repo, target_id) + } + } + _ => None, + } +} + fn is_reachable( repo: &Repository, - head: git2::Oid, - commit_oid: git2::Oid, - cache: Option<&HashSet>, + head: ObjectId, + commit_oid: ObjectId, + cache: Option<&HashSet>, ) -> bool { if let Some(set) = cache { return set.contains(&commit_oid); } - head == commit_oid || repo.graph_descendant_of(head, commit_oid).unwrap_or(false) + if head == commit_oid { + return true; + } + let walk = match repo.rev_walk([head]).sorting(Sorting::BreadthFirst).all() { + Ok(w) => w, + Err(_) => return false, + }; + for info in walk.flatten() { + if info.id == commit_oid { + return true; + } + } + false } pub(super) struct TagMatch { pub name: String, - pub commit_oid: git2::Oid, + pub commit_oid: ObjectId, pub time: i64, } pub(super) fn find_matching_commit( repo: &Repository, - orphaned_commit: &git2::Commit, + orphaned_commit: &gix::Commit<'_>, strategy: &OrphanedTagStrategy, -) -> Option { - let mut walk = repo.revwalk().ok()?; - walk.push_head().ok()?; - walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) +) -> Option { + let head = repo.head_id().ok()?.detach(); + let walk = repo + .rev_walk([head]) + .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) + .all() .ok()?; let limit = 1000; - - for (count, oid) in walk.enumerate() { + let orphan_tree = orphaned_commit.tree_id().ok()?.detach(); + let orphan_message = orphaned_commit + .message_raw() + .ok() + .map(|m| m.to_vec()) + .unwrap_or_default(); + + for (count, info) in walk.enumerate() { if count >= limit { break; } - let oid = match oid { - Ok(o) => o, + let info = match info { + Ok(i) => i, Err(_) => continue, }; - let candidate = match repo.find_commit(oid) { + let candidate = match repo.find_commit(info.id) { Ok(c) => c, Err(_) => continue, }; let matched = match strategy { - OrphanedTagStrategy::TreeHash => candidate.tree_id() == orphaned_commit.tree_id(), - OrphanedTagStrategy::Message => candidate.message() == orphaned_commit.message(), + OrphanedTagStrategy::TreeHash => candidate + .tree_id() + .ok() + .map(|t| t.detach() == orphan_tree) + .unwrap_or(false), + OrphanedTagStrategy::Message => candidate + .message_raw() + .ok() + .map(|m| { + let bytes: &[u8] = m; + bytes == orphan_message.as_slice() + }) + .unwrap_or(false), OrphanedTagStrategy::Warn => return None, }; if matched { - return Some(oid); + return Some(info.id); } } None @@ -269,53 +292,61 @@ pub(super) fn find_last_tag_with_cache( repo: &Repository, prefix: &str, strategy: OrphanedTagStrategy, - ancestors: Option<&HashSet>, + ancestors: Option<&HashSet>, ) -> Result> { - let head = repo.head()?.peel_to_commit()?.id(); - let latest: RefCell> = RefCell::new(None); - let warnings: RefCell> = RefCell::new(Vec::new()); - - repo.tag_foreach(|oid, name| { - let name = String::from_utf8_lossy(name); - let tag_name = name.trim_start_matches("refs/tags/"); - if !tag_name.starts_with(prefix) || is_floating_tag(tag_name, prefix) { - return true; + let head = repo.head_id()?.detach(); + let mut latest: Option = None; + let references = repo.references()?; + + for reference in references.tags()?.flatten() { + let tag_name = String::from_utf8_lossy(reference.name().shorten()).into_owned(); + if !tag_name.starts_with(prefix) || is_floating_tag(&tag_name, prefix) { + continue; } - let commit_oid = if let Ok(tag_obj) = repo.find_tag(oid) { - tag_obj.target_id() - } else { - oid + let raw_oid = reference.id().detach(); + let commit_oid = match resolve_tag_to_commit(repo, raw_oid) { + Some(oid) => oid, + None => { + eprintln!( + "Warning: tag '{}' points to missing commit {} (likely garbage-collected). Skipping.\n \ + Hint: set 'orphanedTagStrategy' to 'treeHash' or 'message' for automatic recovery.\n \ + See https://ferrflow.com/docs/configuration/config-file#orphaned-tag-strategy", + tag_name, + &raw_oid.to_string()[..7] + ); + continue; + } }; let commit = match repo.find_commit(commit_oid) { Ok(c) => c, Err(_) => { - warnings.borrow_mut().push(format!( + eprintln!( "Warning: tag '{}' points to missing commit {} (likely garbage-collected). Skipping.\n \ Hint: set 'orphanedTagStrategy' to 'treeHash' or 'message' for automatic recovery.\n \ See https://ferrflow.com/docs/configuration/config-file#orphaned-tag-strategy", tag_name, &commit_oid.to_string()[..7] - )); - return true; + ); + continue; } }; let reachable = is_reachable(repo, head, commit_oid, ancestors); let (effective_oid, effective_time) = if reachable { - (commit_oid, commit.time().seconds()) + (commit_oid, commit.time().map(|t| t.seconds).unwrap_or(0)) } else { - let short = &commit_oid.to_string()[..7]; + let short = &commit_oid.to_string()[..7].to_string(); if strategy == OrphanedTagStrategy::Warn { - warnings.borrow_mut().push(format!( + eprintln!( "Warning: tag '{}' points to orphaned commit {} (not reachable from HEAD).\n \ Hint: set 'orphanedTagStrategy' to 'treeHash' or 'message' for automatic recovery.\n \ See https://ferrflow.com/docs/configuration/config-file#orphaned-tag-strategy", tag_name, short - )); - return true; + ); + continue; } match find_matching_commit(repo, &commit, &strategy) { Some(matched_oid) => { @@ -324,17 +355,20 @@ pub(super) fn find_last_tag_with_cache( OrphanedTagStrategy::Message => "message", OrphanedTagStrategy::Warn => unreachable!(), }; - warnings.borrow_mut().push(format!( + eprintln!( "Info: tag '{}' was orphaned but matched commit {} on current branch via {}.", tag_name, &matched_oid.to_string()[..7], strategy_name - )); + ); let matched_commit = match repo.find_commit(matched_oid) { Ok(c) => c, - Err(_) => return true, + Err(_) => continue, }; - (matched_oid, matched_commit.time().seconds()) + ( + matched_oid, + matched_commit.time().map(|t| t.seconds).unwrap_or(0), + ) } None => { let strategy_name = match strategy { @@ -342,32 +376,26 @@ pub(super) fn find_last_tag_with_cache( OrphanedTagStrategy::Message => "message", OrphanedTagStrategy::Warn => unreachable!(), }; - warnings.borrow_mut().push(format!( + eprintln!( "Warning: tag '{}' points to orphaned commit {}. No match found via {}. Skipping.\n \ Hint: re-tag manually with 'git tag -f {} '", tag_name, short, strategy_name, tag_name - )); - return true; + ); + continue; } } }; - let mut latest_ref = latest.borrow_mut(); - if latest_ref.is_none() || effective_time > latest_ref.as_ref().unwrap().time { - *latest_ref = Some(TagMatch { - name: tag_name.to_string(), + if latest.as_ref().is_none_or(|l| effective_time > l.time) { + latest = Some(TagMatch { + name: tag_name, commit_oid: effective_oid, time: effective_time, }); } - true - })?; - - for w in warnings.borrow().iter() { - eprintln!("{}", w); } - Ok(latest.into_inner()) + Ok(latest) } pub fn find_last_tag_name( @@ -382,13 +410,11 @@ pub fn find_last_tag_name_with_cache( repo: &Repository, prefix: &str, strategy: OrphanedTagStrategy, - ancestors: Option<&HashSet>, + ancestors: Option<&HashSet>, ) -> Result> { Ok(find_last_tag_with_cache(repo, prefix, strategy, ancestors)?.map(|t| t.name)) } -// Kept as a public no-cache convenience for single-shot callers (and the -// existing test suite). The cached variant below is the perf path. #[allow(dead_code)] pub fn find_highest_semver_tag( repo: &Repository, @@ -402,71 +428,67 @@ pub fn find_highest_semver_tag_with_cache( repo: &Repository, prefix: &str, strategy: OrphanedTagStrategy, - ancestors: Option<&HashSet>, + ancestors: Option<&HashSet>, ) -> Result> { - let head = repo.head()?.peel_to_commit()?.id(); - let highest: RefCell> = RefCell::new(None); + let head = repo.head_id()?.detach(); + let mut highest: Option<(String, semver::Version)> = None; + let references = repo.references()?; - repo.tag_foreach(|oid, name| { - let name = String::from_utf8_lossy(name); - let tag_name = name.trim_start_matches("refs/tags/"); + for reference in references.tags()?.flatten() { + let tag_name = String::from_utf8_lossy(reference.name().shorten()).into_owned(); if !tag_name.starts_with(prefix) - || is_prerelease_tag(tag_name, prefix) - || is_floating_tag(tag_name, prefix) + || is_prerelease_tag(&tag_name, prefix) + || is_floating_tag(&tag_name, prefix) { - return true; + continue; } let version_str = tag_name .strip_prefix(prefix) .map(|s| s.strip_prefix('v').unwrap_or(s)) - .unwrap_or(tag_name); + .unwrap_or(&tag_name); let parsed = match semver::Version::parse(version_str) { Ok(v) => v, - Err(_) => return true, + Err(_) => continue, }; - let commit_oid = if let Ok(tag_obj) = repo.find_tag(oid) { - tag_obj.target_id() - } else { - oid + let raw_oid = reference.id().detach(); + let commit_oid = match resolve_tag_to_commit(repo, raw_oid) { + Some(oid) => oid, + None => continue, }; let commit = match repo.find_commit(commit_oid) { Ok(c) => c, - Err(_) => return true, + Err(_) => continue, }; let reachable = is_reachable(repo, head, commit_oid, ancestors); if !reachable { match strategy { - OrphanedTagStrategy::Warn => return true, + OrphanedTagStrategy::Warn => continue, OrphanedTagStrategy::TreeHash | OrphanedTagStrategy::Message => { if find_matching_commit(repo, &commit, &strategy).is_none() { - return true; + continue; } } } } - let mut highest_ref = highest.borrow_mut(); - match highest_ref.as_ref() { + match highest.as_ref() { Some((_, existing)) if existing >= &parsed => {} _ => { - *highest_ref = Some((tag_name.to_string(), parsed)); + highest = Some((tag_name, parsed)); } } - true - })?; + } - Ok(highest - .into_inner() - .map(|(name, version)| (name, version.to_string()))) + Ok(highest.map(|(name, version)| (name, version.to_string()))) } pub(super) fn find_last_tag_commit( repo: &Repository, prefix: &str, strategy: OrphanedTagStrategy, -) -> Result> { +) -> Result> { Ok(find_last_tag(repo, prefix, strategy)?.map(|t| t.commit_oid)) } @@ -482,101 +504,85 @@ pub(super) fn find_last_stable_tag_with_cache( repo: &Repository, prefix: &str, strategy: OrphanedTagStrategy, - ancestors: Option<&HashSet>, + ancestors: Option<&HashSet>, ) -> Result> { - let head = repo.head()?.peel_to_commit()?.id(); - let latest: RefCell> = RefCell::new(None); + let head = repo.head_id()?.detach(); + let mut latest: Option = None; + let references = repo.references()?; - repo.tag_foreach(|oid, name| { - let name = String::from_utf8_lossy(name); - let tag_name = name.trim_start_matches("refs/tags/"); + for reference in references.tags()?.flatten() { + let tag_name = String::from_utf8_lossy(reference.name().shorten()).into_owned(); if !tag_name.starts_with(prefix) - || is_prerelease_tag(tag_name, prefix) - || is_floating_tag(tag_name, prefix) + || is_prerelease_tag(&tag_name, prefix) + || is_floating_tag(&tag_name, prefix) { - return true; + continue; } - let commit_oid = if let Ok(tag_obj) = repo.find_tag(oid) { - tag_obj.target_id() - } else { - oid + let raw_oid = reference.id().detach(); + let commit_oid = match resolve_tag_to_commit(repo, raw_oid) { + Some(oid) => oid, + None => continue, }; let commit = match repo.find_commit(commit_oid) { Ok(c) => c, - Err(_) => return true, + Err(_) => continue, }; let reachable = is_reachable(repo, head, commit_oid, ancestors); let (effective_oid, effective_time) = if reachable { - (commit_oid, commit.time().seconds()) + (commit_oid, commit.time().map(|t| t.seconds).unwrap_or(0)) } else { if strategy == OrphanedTagStrategy::Warn { - return true; + continue; } match find_matching_commit(repo, &commit, &strategy) { Some(matched_oid) => { let matched_commit = match repo.find_commit(matched_oid) { Ok(c) => c, - Err(_) => return true, + Err(_) => continue, }; - (matched_oid, matched_commit.time().seconds()) + ( + matched_oid, + matched_commit.time().map(|t| t.seconds).unwrap_or(0), + ) } - None => return true, + None => continue, } }; - let mut latest_ref = latest.borrow_mut(); - if latest_ref.is_none() || effective_time > latest_ref.as_ref().unwrap().time { - *latest_ref = Some(TagMatch { - name: tag_name.to_string(), + if latest.as_ref().is_none_or(|l| effective_time > l.time) { + latest = Some(TagMatch { + name: tag_name, commit_oid: effective_oid, time: effective_time, }); } - true - })?; + } - Ok(latest.into_inner()) + Ok(latest) } pub fn collect_all_tags(repo: &Repository) -> Vec { - let workdir = match repo.workdir() { - Some(p) => p, - None => return collect_all_tags_libgit2(repo), + let references = match repo.references() { + Ok(r) => r, + Err(_) => return Vec::new(), }; - match gix::open(workdir) { - Ok(gix_repo) => { - collect_all_tags_gix(&gix_repo).unwrap_or_else(|_| collect_all_tags_libgit2(repo)) - } - Err(_) => collect_all_tags_libgit2(repo), - } -} - -fn collect_all_tags_gix(repo: &gix::Repository) -> Result> { - let references = repo.references()?; let mut tags = Vec::new(); - for reference in references.tags()?.flatten() { - let name = reference.name().shorten(); - tags.push(String::from_utf8_lossy(name.as_ref()).into_owned()); + if let Ok(iter) = references.tags() { + for reference in iter.flatten() { + let name = reference.name().shorten(); + tags.push(String::from_utf8_lossy(name).into_owned()); + } } - Ok(tags) -} - -fn collect_all_tags_libgit2(repo: &Repository) -> Vec { - let mut tags = Vec::new(); - let _ = repo.tag_foreach(|_oid, name| { - let name = String::from_utf8_lossy(name); - tags.push(name.trim_start_matches("refs/tags/").to_string()); - true - }); tags } pub fn tag_exists(repo: &Repository, tag_name: &str) -> bool { - repo.refname_to_id(&format!("refs/tags/{tag_name}")).is_ok() + repo.find_reference(&format!("refs/tags/{tag_name}")) + .is_ok() } pub fn create_tag(repo: &Repository, tag_name: &str, message: &str) -> Result<()> { @@ -584,26 +590,36 @@ pub fn create_tag(repo: &Repository, tag_name: &str, message: &str) -> Result<() Err(anyhow::anyhow!("tag {tag_name} already exists")) .error_code(error_code::GIT_TAG_EXISTS)?; } - let head = repo.head()?.peel_to_commit()?; - let sig = signature(repo)?; - repo.tag(tag_name, head.as_object(), &sig, message, false)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?; + run_git(workdir, &["tag", "-a", tag_name, "-m", message]) + .with_context(|| format!("git tag -a {tag_name} failed"))?; Ok(()) } pub fn create_or_move_tag(repo: &Repository, tag_name: &str, message: &str) -> Result { + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Bare repositories are not supported"))?; let existed = tag_exists(repo, tag_name); if existed { - repo.tag_delete(tag_name)?; + run_git(workdir, &["tag", "-d", tag_name]) + .with_context(|| format!("git tag -d {tag_name} failed"))?; } - let head = repo.head()?.peel_to_commit()?; - let sig = signature(repo)?; - repo.tag(tag_name, head.as_object(), &sig, message, false)?; + run_git(workdir, &["tag", "-a", tag_name, "-m", message]) + .with_context(|| format!("git tag -a {tag_name} failed"))?; Ok(existed) } pub fn get_tag_message(repo: &Repository, tag_name: &str) -> Option { - let oid = repo.refname_to_id(&format!("refs/tags/{tag_name}")).ok()?; - let obj = repo.find_object(oid, None).ok()?; - let tag = obj.as_tag()?; - tag.message().map(String::from) + let reference = repo.find_reference(&format!("refs/tags/{tag_name}")).ok()?; + let oid = reference.id().detach(); + let object = repo.find_object(oid).ok()?; + if !matches!(object.kind, gix::object::Kind::Tag) { + return None; + } + let tag = object.try_into_tag().ok()?; + let decoded = tag.decode().ok()?; + Some(decoded.message.to_string()) } diff --git a/src/git/tests.rs b/src/git/tests.rs index e53dd4b..99824ad 100644 --- a/src/git/tests.rs +++ b/src/git/tests.rs @@ -1,14 +1,12 @@ -use super::auth::{ - configure_git_command, credentials_callback, extract_url_password, token_for_url, -}; +use super::auth::{configure_git_command, extract_url_password, token_for_url}; use super::push::{fetch_and_rebase, parse_ls_remote_tags}; +use super::repo::Repository; use super::retry::{is_transient_git_error, retry_transient}; use super::tags::{find_highest_semver_tag, find_last_tag, is_floating_tag, is_prerelease_tag}; use super::*; use crate::config::OrphanedTagStrategy; use crate::error_code; -use git2::{CredentialType, Repository, Signature}; -use std::fs; +use crate::test_utils::{git, git_with_env, init_repo_at}; use std::path::Path; #[test] @@ -138,55 +136,36 @@ fn parse_ls_remote_tags_handles_multiple_tags_mixed_types() { fn init_repo() -> (tempfile::TempDir, Repository) { let dir = tempfile::tempdir().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - - // Configure user for commits - let mut config = repo.config().unwrap(); - config.set_str("user.name", "Test").unwrap(); - config.set_str("user.email", "test@test.com").unwrap(); - + let repo = init_repo_at(dir.path()); (dir, repo) } -/// Counter to give each commit a distinct timestamp in tests. static COMMIT_TIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1_700_000_000); -fn create_commit_in_repo(repo: &Repository, dir: &Path, filename: &str, message: &str) { - let file_path = dir.join(filename); - fs::write(&file_path, format!("content of {filename}")).unwrap(); - - let mut index = repo.index().unwrap(); - index.add_path(Path::new(filename)).unwrap(); - index.write().unwrap(); - - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - - // Use an incrementing timestamp so commits have deterministic ordering - let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let sig = Signature::new("Test", "test@test.com", &git2::Time::new(ts, 0)).unwrap(); - - let parents: Vec = match repo.head() { - Ok(head) => vec![head.peel_to_commit().unwrap()], - Err(_) => vec![], - }; - let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); +fn next_commit_ts() -> i64 { + COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst) +} - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs) - .unwrap(); +fn create_commit_in_repo(_repo: &Repository, dir: &Path, filename: &str, message: &str) { + std::fs::write(dir.join(filename), format!("content of {filename}")).unwrap(); + git(dir, &["add", "--", filename]); + let ts = next_commit_ts(); + let date = format!("{ts} +0000"); + git_with_env( + dir, + &["commit", "-m", message], + &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], + ); } fn create_lightweight_tag(repo: &Repository, tag_name: &str) { - let head = repo.head().unwrap().peel_to_commit().unwrap(); - repo.tag_lightweight(tag_name, head.as_object(), false) - .unwrap(); + let workdir = repo.workdir().expect("workdir"); + git(workdir, &["tag", tag_name]); } fn create_annotated_tag(repo: &Repository, tag_name: &str, message: &str) { - let head = repo.head().unwrap().peel_to_commit().unwrap(); - let sig = Signature::now("Test", "test@test.com").unwrap(); - repo.tag(tag_name, head.as_object(), &sig, message, false) - .unwrap(); + let workdir = repo.workdir().expect("workdir"); + git(workdir, &["tag", "-a", tag_name, "-m", message]); } // ----------------------------------------------------------------------- @@ -205,7 +184,7 @@ fn open_repo_not_a_repo() { let dir = tempfile::tempdir().unwrap(); // Empty dir, no .git let sub = dir.path().join("not_a_repo"); - fs::create_dir_all(&sub).unwrap(); + std::fs::create_dir_all(&sub).unwrap(); assert!(open_repo(&sub).is_err()); } @@ -410,35 +389,15 @@ fn find_highest_semver_respects_orphan_warn_strategy() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "first"); create_lightweight_tag(&repo, "api@v1.0.0"); - // Create a second branch, tag v9.0.0 on it, then abandon it by moving - // HEAD back to the main branch. - let main_commit = repo.head().unwrap().peel_to_commit().unwrap(); - repo.branch("orphan", &main_commit, false).unwrap(); - repo.set_head("refs/heads/orphan").unwrap(); + let initial_sha = git(dir.path(), &["rev-parse", "HEAD"]).trim().to_string(); + git(dir.path(), &["checkout", "-b", "orphan"]); create_commit_in_repo(&repo, dir.path(), "b.txt", "orphan-only"); create_lightweight_tag(&repo, "api@v9.0.0"); - // Back to the original branch (HEAD does not include v9.0.0 anymore). - repo.set_head( - repo.head() - .unwrap() - .shorthand() - .map(|_| "refs/heads/master") - .unwrap_or("refs/heads/master"), - ) - .unwrap(); - // reset HEAD to the initial commit - let initial_oid = main_commit.id(); - repo.reference( - "refs/heads/master", - initial_oid, - true, - "reset after orphan test", - ) - .unwrap(); - repo.set_head("refs/heads/master").unwrap(); - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); + git(dir.path(), &["checkout", "main"]); + git(dir.path(), &["update-ref", "refs/heads/main", &initial_sha]); + git(dir.path(), &["checkout", "-f", "main"]); + let repo = open_repo(dir.path()).unwrap(); let result = find_highest_semver_tag(&repo, "api@v", OrphanedTagStrategy::Warn) .unwrap() .unwrap(); @@ -548,11 +507,11 @@ fn create_commit_adds_files() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "initial"); - fs::write(dir.path().join("new.txt"), "new content").unwrap(); + std::fs::write(dir.path().join("new.txt"), "new content").unwrap(); create_commit(&repo, &["new.txt"], "feat: add new file").unwrap(); - let head = repo.head().unwrap().peel_to_commit().unwrap(); - assert!(head.message().unwrap().contains("feat: add new file")); + let msg = git(dir.path(), &["log", "-1", "--format=%B"]); + assert!(msg.contains("feat: add new file")); } // ----------------------------------------------------------------------- @@ -564,14 +523,14 @@ fn create_branch_and_commit_works() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "initial"); - fs::write(dir.path().join("release.txt"), "bumped").unwrap(); + std::fs::write(dir.path().join("release.txt"), "bumped").unwrap(); create_branch_and_commit(&repo, "release/v1.0.0", &["release.txt"], "chore: release").unwrap(); - // Branch should exist - assert!( - repo.find_branch("release/v1.0.0", git2::BranchType::Local) - .is_ok() + let out = git( + dir.path(), + &["rev-parse", "--verify", "refs/heads/release/v1.0.0"], ); + assert!(!out.trim().is_empty()); } #[test] @@ -579,8 +538,8 @@ fn create_branch_and_commits_multiple() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "initial"); - fs::write(dir.path().join("pkg1.txt"), "v1").unwrap(); - fs::write(dir.path().join("pkg2.txt"), "v2").unwrap(); + std::fs::write(dir.path().join("pkg1.txt"), "v1").unwrap(); + std::fs::write(dir.path().join("pkg2.txt"), "v2").unwrap(); let commits: Vec<(&[&str], &str)> = vec![ (&["pkg1.txt"], "chore(release): pkg1 v1.0.0"), @@ -588,13 +547,16 @@ fn create_branch_and_commits_multiple() { ]; create_branch_and_commits(&repo, "release/multi", &commits).unwrap(); - let branch = repo - .find_branch("release/multi", git2::BranchType::Local) - .unwrap(); - let tip = branch.get().peel_to_commit().unwrap(); - assert_eq!(tip.message().unwrap(), "chore(release): pkg2 v2.0.0"); - let parent = tip.parent(0).unwrap(); - assert_eq!(parent.message().unwrap(), "chore(release): pkg1 v1.0.0"); + let tip_msg = git( + dir.path(), + &["log", "-1", "--format=%B", "refs/heads/release/multi"], + ); + assert!(tip_msg.contains("chore(release): pkg2 v2.0.0")); + let parent_msg = git( + dir.path(), + &["log", "-1", "--format=%B", "refs/heads/release/multi^"], + ); + assert!(parent_msg.contains("chore(release): pkg1 v1.0.0")); } // ----------------------------------------------------------------------- @@ -605,13 +567,22 @@ fn create_branch_and_commits_multiple() { fn get_remote_url_https() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "initial"); - repo.remote("origin", "https://github.com/FerrLabs/FerrFlow.git") - .unwrap(); + git( + dir.path(), + &[ + "remote", + "add", + "origin", + "https://github.com/FerrLabs/FerrFlow.git", + ], + ); + let repo = open_repo(dir.path()).unwrap(); let url = get_remote_url(&repo, "origin"); assert_eq!( url, Some("https://github.com/FerrLabs/FerrFlow.git".to_string()) ); + let _ = repo; } #[test] @@ -839,25 +810,23 @@ fn create_orphaned_tag_scenario(tag_name: &str) -> (Repository, tempfile::TempDi create_commit_in_repo(&repo, dir.path(), "a.txt", "feat: original"); create_lightweight_tag(&repo, tag_name); - // Create a new root commit with the same tree and message (simulates rebase). - // We write the commit without updating HEAD, then force-move HEAD to it. - { - let head = repo.head().unwrap().peel_to_commit().unwrap(); - let tree = head.tree().unwrap(); - let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let sig = Signature::new("Test", "test@test.com", &git2::Time::new(ts, 0)).unwrap(); - let old_id = head.id(); - let new_oid = repo - .commit(None, &sig, &sig, "feat: original", &tree, &[]) - .unwrap(); - assert_ne!(old_id, new_oid); - // Force-move the current branch to the new orphan commit - let head_ref = repo.head().unwrap(); - let branch_name = head_ref.name().unwrap(); - repo.reference(branch_name, new_oid, true, "force-move for test") - .unwrap(); - } - + let head_branch = git(dir.path(), &["symbolic-ref", "HEAD"]) + .trim() + .to_string(); + let tree_sha = git(dir.path(), &["rev-parse", "HEAD^{tree}"]) + .trim() + .to_string(); + let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let date = format!("{ts} +0000"); + let new_sha = git_with_env( + dir.path(), + &["commit-tree", &tree_sha, "-m", "feat: original"], + &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], + ) + .trim() + .to_string(); + git(dir.path(), &["update-ref", &head_branch, &new_sha]); + let repo = open_repo(dir.path()).unwrap(); (repo, dir) } @@ -888,24 +857,23 @@ fn orphaned_tag_no_match() { create_commit_in_repo(&repo, dir.path(), "a.txt", "feat: original"); create_lightweight_tag(&repo, "v1.0.0"); - // Create a completely different root commit (different tree and message) - { - let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let sig = Signature::new("Test", "test@test.com", &git2::Time::new(ts, 0)).unwrap(); - fs::write(dir.path().join("b.txt"), "different").unwrap(); - let mut index = repo.index().unwrap(); - index.add_path(Path::new("b.txt")).unwrap(); - index.write().unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let new_oid = repo - .commit(None, &sig, &sig, "feat: totally different", &tree, &[]) - .unwrap(); - let head_ref = repo.head().unwrap(); - let branch_name = head_ref.name().unwrap(); - repo.reference(branch_name, new_oid, true, "force-move for test") - .unwrap(); - } + let head_branch = git(dir.path(), &["symbolic-ref", "HEAD"]) + .trim() + .to_string(); + std::fs::write(dir.path().join("b.txt"), "different").unwrap(); + git(dir.path(), &["add", "--", "b.txt"]); + let tree_sha = git(dir.path(), &["write-tree"]).trim().to_string(); + let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let date = format!("{ts} +0000"); + let new_sha = git_with_env( + dir.path(), + &["commit-tree", &tree_sha, "-m", "feat: totally different"], + &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], + ) + .trim() + .to_string(); + git(dir.path(), &["update-ref", &head_branch, &new_sha]); + let repo = open_repo(dir.path()).unwrap(); let result = find_last_tag_name(&repo, "v", OrphanedTagStrategy::TreeHash).unwrap(); assert_eq!(result, None); @@ -954,67 +922,9 @@ fn collect_all_tags_returns_tag_names() { assert!(tags.contains(&"v1.1.0-beta.1".to_string())); } -#[test] -fn credentials_callback_uses_oauth2_for_gitlab() { - unsafe { std::env::set_var("FERRFLOW_TOKEN", "test-token") }; - let result = credentials_callback( - "https://gitlab.com/group/project.git", - None, - CredentialType::USER_PASS_PLAINTEXT, - ); - unsafe { std::env::remove_var("FERRFLOW_TOKEN") }; - assert!(result.is_ok()); -} - -#[test] -fn credentials_callback_uses_x_access_token_for_github() { - unsafe { std::env::set_var("FERRFLOW_TOKEN", "test-token") }; - let result = credentials_callback( - "https://github.com/owner/repo.git", - None, - CredentialType::USER_PASS_PLAINTEXT, - ); - unsafe { std::env::remove_var("FERRFLOW_TOKEN") }; - assert!(result.is_ok()); -} - -#[test] -fn credentials_callback_falls_back_to_github_token() { - unsafe { std::env::remove_var("FERRFLOW_TOKEN") }; - unsafe { std::env::set_var("GITHUB_TOKEN", "gh-fallback-token") }; - let result = credentials_callback( - "https://github.com/owner/repo.git", - None, - CredentialType::USER_PASS_PLAINTEXT, - ); - unsafe { std::env::remove_var("GITHUB_TOKEN") }; - assert!(result.is_ok()); -} - -#[test] -fn credentials_callback_falls_back_to_gitlab_token() { - unsafe { std::env::remove_var("FERRFLOW_TOKEN") }; - unsafe { std::env::set_var("GITLAB_TOKEN", "gl-fallback-token") }; - let result = credentials_callback( - "https://gitlab.com/group/project.git", - None, - CredentialType::USER_PASS_PLAINTEXT, - ); - unsafe { std::env::remove_var("GITLAB_TOKEN") }; - assert!(result.is_ok()); -} - -#[test] -fn credentials_callback_uses_oauth2_for_self_hosted_gitlab() { - unsafe { std::env::set_var("FERRFLOW_TOKEN", "test-token") }; - let result = credentials_callback( - "https://git.example.gitlab.com/group/project.git", - None, - CredentialType::USER_PASS_PLAINTEXT, - ); - unsafe { std::env::remove_var("FERRFLOW_TOKEN") }; - assert!(result.is_ok()); -} +// Note: credentials_callback was deleted with the libgit2 dependency. +// The token_for_url tests below cover the equivalent behaviour for the +// new credential helper protocol path. #[test] fn is_prerelease_tag_detection() { @@ -1058,21 +968,10 @@ fn find_last_tag_skips_floating_tags() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "feat: initial"); - repo.tag_lightweight( - "v1.0.0", - &repo.head().unwrap().peel_to_commit().unwrap().into_object(), - false, - ) - .unwrap(); + git(dir.path(), &["tag", "v1.0.0"]); create_commit_in_repo(&repo, dir.path(), "b.txt", "feat: second"); - // Create a floating tag pointing to a newer commit - repo.tag_lightweight( - "v1", - &repo.head().unwrap().peel_to_commit().unwrap().into_object(), - false, - ) - .unwrap(); + git(dir.path(), &["tag", "v1"]); let result = find_last_tag(&repo, "v", OrphanedTagStrategy::Warn) .unwrap() @@ -1098,11 +997,10 @@ fn resolve_branch_from_head() { fn resolve_branch_detached_returns_non_empty() { let (dir, repo) = init_repo(); create_commit_in_repo(&repo, dir.path(), "a.txt", "initial"); - let head_oid = repo.head().unwrap().target().unwrap(); - repo.set_head_detached(head_oid).unwrap(); + let head_oid = git(dir.path(), &["rev-parse", "HEAD"]).trim().to_string(); + git(dir.path(), &["checkout", "--detach", &head_oid]); + let repo = open_repo(dir.path()).unwrap(); - // In detached state, the function should return either a CI env var - // or the fallback — never an empty string. let branch = resolve_current_branch(&repo, "my-fallback"); assert!(!branch.is_empty()); } @@ -1125,113 +1023,58 @@ fn resolve_branch_detached_returns_non_empty() { /// every file B had touched. #[test] fn fetch_and_rebase_preserves_concurrent_remote_changes() { - use std::path::Path as StdPath; let base_dir = tempfile::tempdir().unwrap(); - // --- Set up a bare "remote" repo --- - // init_bare picks the default branch name from git's init.defaultBranch - // config, which differs by platform (master on some Linux distros, - // main on newer ones, etc.). Avoid the whole problem by not using the - // bare's default at all: we create commits locally, set our *own* - // HEAD to a fixed branch name, and point the bare's HEAD at it - // symbolically. let remote_path = base_dir.path().join("remote.git"); - let bare = Repository::init_bare(&remote_path).unwrap(); - // Make the bare's HEAD symbolic ref target "main" so clones pick that. - bare.set_head("refs/heads/main").unwrap(); - drop(bare); + std::fs::create_dir_all(&remote_path).unwrap(); + git(&remote_path, &["init", "--bare", "-b", "main"]); - // --- Set up a local working repo (non-clone to avoid default-branch - // pitfalls) and wire origin to the bare remote manually. --- let local_path = base_dir.path().join("local"); std::fs::create_dir_all(&local_path).unwrap(); - let repo = Repository::init(&local_path).unwrap(); - { - let mut cfg = repo.config().unwrap(); - cfg.set_str("user.name", "Test").unwrap(); - cfg.set_str("user.email", "test@test.com").unwrap(); - } - repo.remote("origin", remote_path.to_str().unwrap()) - .unwrap(); - // Pin local HEAD to refs/heads/main so create_commit_in_repo commits - // onto the branch we expect. - repo.set_head("refs/heads/main").unwrap(); + let repo = init_repo_at(&local_path); + git( + &local_path, + &["remote", "add", "origin", remote_path.to_str().unwrap()], + ); create_commit_in_repo(&repo, &local_path, "base.txt", "commit A"); - // Push A to the remote so it becomes the shared base. - repo.find_remote("origin") - .unwrap() - .push(&["refs/heads/main:refs/heads/main"], None) - .unwrap(); - let base_oid = repo.head().unwrap().target().unwrap(); + git(&local_path, &["push", "origin", "main:main"]); - // --- Advance the remote with commit B (simulated concurrent merge) --- - // Same init-then-add-remote dance to keep the branch naming under our - // control. let helper_path = base_dir.path().join("helper"); std::fs::create_dir_all(&helper_path).unwrap(); - let helper = Repository::init(&helper_path).unwrap(); - { - let mut cfg = helper.config().unwrap(); - cfg.set_str("user.name", "Helper").unwrap(); - cfg.set_str("user.email", "helper@test.com").unwrap(); - } - helper - .remote("origin", remote_path.to_str().unwrap()) - .unwrap(); - // Fetch + check out main from the remote so we commit on top of A, - // not on an unrelated root. - helper - .find_remote("origin") - .unwrap() - .fetch(&["refs/heads/main:refs/heads/main"], None, None) - .unwrap(); - helper.set_head("refs/heads/main").unwrap(); - helper - .checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - create_commit_in_repo(&helper, &helper_path, "from_concurrent_pr.txt", "commit B"); - helper - .find_remote("origin") - .unwrap() - .push(&["refs/heads/main:refs/heads/main"], None) - .unwrap(); + let helper_repo = init_repo_at(&helper_path); + git( + &helper_path, + &["remote", "add", "origin", remote_path.to_str().unwrap()], + ); + git( + &helper_path, + &["fetch", "origin", "main:refs/remotes/origin/main"], + ); + git( + &helper_path, + &["reset", "--hard", "refs/remotes/origin/main"], + ); + create_commit_in_repo( + &helper_repo, + &helper_path, + "from_concurrent_pr.txt", + "commit B", + ); + git(&helper_path, &["push", "origin", "main:main"]); - // --- Back in local, create commit X on top of A (the release commit) --- - // Local HEAD is still at A at this point (we haven't fetched). - assert_eq!(repo.head().unwrap().target().unwrap(), base_oid); create_commit_in_repo(&repo, &local_path, "release_commit.txt", "commit X"); - // --- Call fetch_and_rebase on local --- + let repo = open_repo(&local_path).unwrap(); fetch_and_rebase(&repo, "origin", "main").expect("rebase should succeed"); - // --- Verify: the resulting HEAD tree contains BOTH B's file and X's file --- - let tip = repo.head().unwrap().peel_to_commit().unwrap(); - let tree = tip.tree().unwrap(); - assert!( - tree.get_path(StdPath::new("base.txt")).is_ok(), - "base.txt (from A) must be in the rebased tree" - ); - assert!( - tree.get_path(StdPath::new("from_concurrent_pr.txt")) - .is_ok(), - "from_concurrent_pr.txt (from B — the concurrent remote change) must be in the rebased tree; \ - this is the regression from #367" - ); - assert!( - tree.get_path(StdPath::new("release_commit.txt")).is_ok(), - "release_commit.txt (from X — our local commit being rebased) must be in the rebased tree" - ); + let head_tree = git(&local_path, &["ls-tree", "-r", "--name-only", "HEAD"]); + assert!(head_tree.contains("base.txt")); + assert!(head_tree.contains("from_concurrent_pr.txt")); + assert!(head_tree.contains("release_commit.txt")); - // The new tip's first parent should be B's oid (the remote HEAD we fetched). - let parent = tip.parent(0).unwrap(); - let parent_tree = parent.tree().unwrap(); - assert!( - parent_tree - .get_path(StdPath::new("from_concurrent_pr.txt")) - .is_ok(), - "rebased commit's parent should be B (the fetched remote HEAD)" - ); + let parent_tree = git(&local_path, &["ls-tree", "-r", "--name-only", "HEAD^"]); + assert!(parent_tree.contains("from_concurrent_pr.txt")); } // ── reset_branch_to_remote — used by the release retry path ───────── @@ -1247,87 +1090,66 @@ fn fetch_and_rebase_preserves_concurrent_remote_changes() { // depends on. #[test] fn reset_branch_to_remote_drops_local_commit_and_dirty_tree() { - use std::path::Path as StdPath; let base_dir = tempfile::tempdir().unwrap(); let remote_path = base_dir.path().join("remote.git"); - let bare = Repository::init_bare(&remote_path).unwrap(); - bare.set_head("refs/heads/main").unwrap(); - drop(bare); + std::fs::create_dir_all(&remote_path).unwrap(); + git(&remote_path, &["init", "--bare", "-b", "main"]); let local_path = base_dir.path().join("local"); std::fs::create_dir_all(&local_path).unwrap(); - let repo = Repository::init(&local_path).unwrap(); - { - let mut cfg = repo.config().unwrap(); - cfg.set_str("user.name", "Test").unwrap(); - cfg.set_str("user.email", "test@test.com").unwrap(); - } - repo.remote("origin", remote_path.to_str().unwrap()) - .unwrap(); - repo.set_head("refs/heads/main").unwrap(); + let repo = init_repo_at(&local_path); + git( + &local_path, + &["remote", "add", "origin", remote_path.to_str().unwrap()], + ); create_commit_in_repo(&repo, &local_path, "base.txt", "commit A"); - repo.find_remote("origin") - .unwrap() - .push(&["refs/heads/main:refs/heads/main"], None) - .unwrap(); + git(&local_path, &["push", "origin", "main:main"]); - // Advance the remote with B. let helper_path = base_dir.path().join("helper"); std::fs::create_dir_all(&helper_path).unwrap(); - let helper = Repository::init(&helper_path).unwrap(); - { - let mut cfg = helper.config().unwrap(); - cfg.set_str("user.name", "Helper").unwrap(); - cfg.set_str("user.email", "helper@test.com").unwrap(); - } - helper - .remote("origin", remote_path.to_str().unwrap()) - .unwrap(); - helper - .find_remote("origin") - .unwrap() - .fetch(&["refs/heads/main:refs/heads/main"], None, None) - .unwrap(); - helper.set_head("refs/heads/main").unwrap(); - helper - .checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - create_commit_in_repo(&helper, &helper_path, "remote_only.txt", "commit B"); - let remote_b_oid = helper.head().unwrap().target().unwrap(); - helper - .find_remote("origin") - .unwrap() - .push(&["refs/heads/main:refs/heads/main"], None) - .unwrap(); + let helper_repo = init_repo_at(&helper_path); + git( + &helper_path, + &["remote", "add", "origin", remote_path.to_str().unwrap()], + ); + git( + &helper_path, + &["fetch", "origin", "main:refs/remotes/origin/main"], + ); + git( + &helper_path, + &["reset", "--hard", "refs/remotes/origin/main"], + ); + create_commit_in_repo(&helper_repo, &helper_path, "remote_only.txt", "commit B"); + let remote_b_oid = git(&helper_path, &["rev-parse", "HEAD"]).trim().to_string(); + git(&helper_path, &["push", "origin", "main:main"]); - // Local: build the release commit X on top of A and add a dirty, - // unstaged file that the cleanup must also wipe. create_commit_in_repo(&repo, &local_path, "release.txt", "commit X"); - let x_oid = repo.head().unwrap().target().unwrap(); + let x_oid = git(&local_path, &["rev-parse", "HEAD"]).trim().to_string(); std::fs::write(local_path.join("dirty.txt"), "stale hook output").unwrap(); + let repo = open_repo(&local_path).unwrap(); reset_branch_to_remote(&repo, "origin", "main").expect("reset must succeed"); - // HEAD is at B, not X. - let new_head = repo.head().unwrap().target().unwrap(); + let new_head = git(&local_path, &["rev-parse", "HEAD"]).trim().to_string(); assert_eq!(new_head, remote_b_oid, "HEAD must be at remote B"); assert_ne!(new_head, x_oid, "HEAD must not still be at X"); - // Working tree was reset: release.txt is gone, base.txt and - // remote_only.txt exist, dirty.txt was wiped. assert!(!local_path.join("release.txt").exists()); assert!(local_path.join("base.txt").exists()); assert!(local_path.join("remote_only.txt").exists()); assert!(!local_path.join("dirty.txt").exists()); - // The branch ref must also point at B, and HEAD must be reattached - // to refs/heads/main (so subsequent commits land on the branch). - let main_ref = repo.find_reference("refs/heads/main").unwrap(); - assert_eq!(main_ref.target().unwrap(), remote_b_oid); - assert!(repo.head().unwrap().is_branch()); - let _ = StdPath::new("dummy"); // silence unused import on some configs + let main_ref = git(&local_path, &["rev-parse", "refs/heads/main"]) + .trim() + .to_string(); + assert_eq!(main_ref, remote_b_oid); + let symref = git(&local_path, &["symbolic-ref", "HEAD"]) + .trim() + .to_string(); + assert_eq!(symref, "refs/heads/main"); } // is_push_rejected_error — used by the retry trigger. diff --git a/src/lib.rs b/src/lib.rs index 1b10f6c..3576c04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,3 +14,82 @@ pub mod git; pub mod telemetry; #[cfg(feature = "cli")] pub mod validate; + +#[cfg(all(test, feature = "cli"))] +pub mod test_utils { + use std::path::Path; + use std::process::Command; + use std::sync::Mutex; + + pub static CWD_LOCK: Mutex<()> = Mutex::new(()); + + pub fn with_cwd anyhow::Result<()>>( + dir: &std::path::Path, + f: F, + ) -> anyhow::Result<()> { + let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + let result = f(); + std::env::set_current_dir(&saved).unwrap(); + result + } + + pub fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap_or_else(|e| panic!("git {} failed to spawn: {}", args.join(" "), e)); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + panic!("git {} failed: {}{}", args.join(" "), stdout, stderr); + } + String::from_utf8_lossy(&out.stdout).into_owned() + } + + pub fn git_with_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> String { + let mut cmd = Command::new("git"); + cmd.current_dir(dir).args(args); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd + .output() + .unwrap_or_else(|e| panic!("git {} failed to spawn: {}", args.join(" "), e)); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + panic!("git {} failed: {}{}", args.join(" "), stdout, stderr); + } + String::from_utf8_lossy(&out.stdout).into_owned() + } + + pub fn init_repo() -> (tempfile::TempDir, crate::git::Repository) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_path_buf(); + let repo = init_repo_at(&path); + (dir, repo) + } + + pub fn init_repo_at(path: &Path) -> crate::git::Repository { + git(path, &["init", "-b", "main"]); + git(path, &["config", "user.name", "Test"]); + git(path, &["config", "user.email", "test@test.com"]); + git(path, &["config", "commit.gpgsign", "false"]); + git(path, &["config", "tag.gpgsign", "false"]); + crate::git::open_repo(path).expect("open_repo after init") + } + + pub fn commit_file(dir: &Path, filename: &str, content: &str, message: &str, ts: i64) { + std::fs::write(dir.join(filename), content).unwrap(); + git(dir, &["add", "--", filename]); + let date = format!("{ts} +0000"); + git_with_env( + dir, + &["commit", "-m", message], + &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], + ); + } +} diff --git a/src/main.rs b/src/main.rs index d32f73a..138d89f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,9 +71,10 @@ fn main() { #[cfg(test)] mod test_utils { + use std::path::Path; + use std::process::Command; use std::sync::Mutex; - /// Global lock for tests that change the process-wide working directory. pub static CWD_LOCK: Mutex<()> = Mutex::new(()); pub fn with_cwd anyhow::Result<()>>( @@ -87,4 +88,62 @@ mod test_utils { std::env::set_current_dir(&saved).unwrap(); result } + + pub fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap_or_else(|e| panic!("git {} failed to spawn: {}", args.join(" "), e)); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + panic!("git {} failed: {}{}", args.join(" "), stdout, stderr); + } + String::from_utf8_lossy(&out.stdout).into_owned() + } + + pub fn git_with_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> String { + let mut cmd = Command::new("git"); + cmd.current_dir(dir).args(args); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd + .output() + .unwrap_or_else(|e| panic!("git {} failed to spawn: {}", args.join(" "), e)); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + panic!("git {} failed: {}{}", args.join(" "), stdout, stderr); + } + String::from_utf8_lossy(&out.stdout).into_owned() + } + + pub fn init_repo() -> (tempfile::TempDir, crate::git::Repository) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_path_buf(); + let repo = init_repo_at(&path); + (dir, repo) + } + + pub fn init_repo_at(path: &Path) -> crate::git::Repository { + git(path, &["init", "-b", "main"]); + git(path, &["config", "user.name", "Test"]); + git(path, &["config", "user.email", "test@test.com"]); + git(path, &["config", "commit.gpgsign", "false"]); + git(path, &["config", "tag.gpgsign", "false"]); + crate::git::open_repo(path).expect("open_repo after init") + } + + pub fn commit_file(dir: &Path, filename: &str, content: &str, message: &str, ts: i64) { + std::fs::write(dir.join(filename), content).unwrap(); + git(dir, &["add", "--", filename]); + let date = format!("{ts} +0000"); + git_with_env( + dir, + &["commit", "-m", message], + &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], + ); + } } diff --git a/src/monorepo/preview.rs b/src/monorepo/preview.rs index c67a733..5487d68 100644 --- a/src/monorepo/preview.rs +++ b/src/monorepo/preview.rs @@ -1,9 +1,8 @@ -use git2::Repository; use std::path::Path; use crate::config::Config; use crate::forge::{self, ForgeKind}; -use crate::git::get_remote_url; +use crate::git::{Repository, get_remote_url}; use super::types::{CheckPackage, CheckResult}; @@ -24,7 +23,7 @@ pub(super) fn build_forge_instance( Some(forge::build_forge(kind, token, slug, host)) } -pub(super) fn post_preview_comment(repo: &git2::Repository, config: &Config, root: &Path) { +pub(super) fn post_preview_comment(repo: &Repository, config: &Config, root: &Path) { let pr_id = match forge::detect_pr_number() { Some(id) => id, None => return, // Not in a PR context, skip silently diff --git a/src/monorepo/release.rs b/src/monorepo/release.rs index f2b6b38..f84b396 100644 --- a/src/monorepo/release.rs +++ b/src/monorepo/release.rs @@ -114,8 +114,13 @@ fn cleanup_failed_release_attempt( let repo = open_repo(root)?; let after: std::collections::HashSet = crate::git::collect_all_tags(&repo).into_iter().collect(); - for tag in after.difference(pre_attempt_tags) { - let _ = repo.tag_delete(tag); + if let Some(workdir) = repo.workdir() { + for tag in after.difference(pre_attempt_tags) { + let _ = std::process::Command::new("git") + .current_dir(workdir) + .args(["tag", "-d", tag]) + .output(); + } } let target_branch = crate::git::resolve_current_branch(&repo, &config.workspace.branch); diff --git a/src/monorepo/run/drafts.rs b/src/monorepo/run/drafts.rs index f6e2268..c481bbe 100644 --- a/src/monorepo/run/drafts.rs +++ b/src/monorepo/run/drafts.rs @@ -1,6 +1,6 @@ +use crate::git::Repository; use anyhow::Result; use colored::Colorize; -use git2::Repository; use std::path::Path; use crate::config::Config; diff --git a/src/monorepo/run/mod.rs b/src/monorepo/run/mod.rs index 8280ed8..37d16c1 100644 --- a/src/monorepo/run/mod.rs +++ b/src/monorepo/run/mod.rs @@ -81,10 +81,9 @@ pub(super) fn run_release_logic( )?; let short_hash = repo - .head() + .head_id() .ok() - .and_then(|h| h.peel_to_commit().ok()) - .map(|c| c.id().to_string()[..7].to_string()) + .map(|id| id.to_string()[..7].to_string()) .unwrap_or_default(); let all_tags = collect_all_tags(&repo); @@ -429,7 +428,7 @@ pub(super) fn run_release_logic( } } } else { - if repo.refname_to_id(&format!("refs/tags/{tag}")).is_ok() { + if crate::git::tag_exists(&repo, &tag) { if let Some((_, lines)) = pkg_outputs.iter_mut().rev().find(|(n, _)| n == &pkg.name) { lines.push(format!( @@ -908,11 +907,7 @@ pub(super) fn run_release_logic( )); } - let target_sha = repo - .head() - .ok() - .and_then(|h| h.peel_to_commit().ok()) - .map(|c| c.id().to_string()); + let target_sha = repo.head_id().ok().map(|id| id.to_string()); if let Some(forge_instance) = build_forge_instance(&repo, config) { for (tag_name, _, body, pkg_name, _, _, is_pre) in &tags_to_create { diff --git a/src/monorepo/util.rs b/src/monorepo/util.rs index 4c071e5..30711b2 100644 --- a/src/monorepo/util.rs +++ b/src/monorepo/util.rs @@ -30,28 +30,39 @@ pub(super) fn pick_higher_semver(file: &str, tag: &str) -> String { } } -pub(super) fn collect_dirty_files(repo: &git2::Repository) -> HashSet { +pub(super) fn collect_dirty_files(repo: &crate::git::Repository) -> HashSet { let mut files = HashSet::new(); - if let Ok(statuses) = repo.statuses(None) { - for entry in statuses.iter() { - let status = entry.status(); - if status.intersects( - git2::Status::WT_MODIFIED - | git2::Status::WT_NEW - | git2::Status::WT_TYPECHANGE - | git2::Status::INDEX_NEW - | git2::Status::INDEX_MODIFIED, - ) && let Some(path) = entry.path() - { - files.insert(path.to_string()); - } + let workdir = match repo.workdir() { + Some(p) => p, + None => return files, + }; + let output = std::process::Command::new("git") + .current_dir(workdir) + .args(["status", "--porcelain=v1", "-z", "--no-renames"]) + .output(); + let output = match output { + Ok(o) if o.status.success() => o, + _ => return files, + }; + let raw = String::from_utf8_lossy(&output.stdout); + for entry in raw.split('\0') { + if entry.len() < 4 { + continue; + } + let xy = &entry[..2]; + let path = entry[3..].to_string(); + let x = xy.as_bytes()[0]; + let y = xy.as_bytes()[1]; + let modified = matches!(y, b'M' | b'?' | b'T' | b'A') || matches!(x, b'A' | b'M'); + if modified { + files.insert(path); } } files } pub(super) fn auto_stage_new_files( - repo: &git2::Repository, + repo: &crate::git::Repository, before: &HashSet, files_to_commit: &mut Vec, ) { diff --git a/src/query.rs b/src/query.rs index 12f9d5e..5351fb8 100644 --- a/src/query.rs +++ b/src/query.rs @@ -222,45 +222,30 @@ pub fn tag(config_path: Option<&std::path::Path>, package: Option<&str>, json: b #[cfg(test)] mod tests { use super::*; - use crate::test_utils::with_cwd; - use git2::{Repository, Signature}; + use crate::test_utils::{commit_file, git, init_repo, with_cwd}; use std::fs; + use std::path::Path; static COMMIT_TIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1_800_000_000); - fn init_repo() -> (tempfile::TempDir, Repository) { - let dir = tempfile::tempdir().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - let mut config = repo.config().unwrap(); - config.set_str("user.name", "Test").unwrap(); - config.set_str("user.email", "test@test.com").unwrap(); - (dir, repo) + fn next_ts() -> i64 { + COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst) } - fn create_commit(repo: &Repository, dir: &std::path::Path, filename: &str, message: &str) { - let file_path = dir.join(filename); - fs::write(&file_path, format!("content of {filename}")).unwrap(); - let mut index = repo.index().unwrap(); - index.add_path(std::path::Path::new(filename)).unwrap(); - index.write().unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let sig = Signature::new("Test", "test@test.com", &git2::Time::new(ts, 0)).unwrap(); - let parents: Vec = match repo.head() { - Ok(head) => vec![head.peel_to_commit().unwrap()], - Err(_) => vec![], - }; - let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs) - .unwrap(); + fn create_commit(_repo: &crate::git::Repository, dir: &Path, filename: &str, message: &str) { + commit_file( + dir, + filename, + &format!("content of {filename}"), + message, + next_ts(), + ); } - fn create_tag(repo: &Repository, tag_name: &str) { - let head = repo.head().unwrap().peel_to_commit().unwrap(); - repo.tag_lightweight(tag_name, head.as_object(), false) - .unwrap(); + fn create_tag(repo: &crate::git::Repository, tag_name: &str) { + let workdir = repo.workdir().expect("workdir"); + git(workdir, &["tag", tag_name]); } fn setup_single_package(dir: &std::path::Path) { diff --git a/src/status.rs b/src/status.rs index d994226..e211228 100644 --- a/src/status.rs +++ b/src/status.rs @@ -104,45 +104,30 @@ fn print_json(statuses: &[PackageStatus]) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::with_cwd; - use git2::{Repository, Signature}; + use crate::test_utils::{commit_file, git, init_repo, with_cwd}; use std::fs; + use std::path::Path; static COMMIT_TIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1_900_000_000); - fn init_repo() -> (tempfile::TempDir, Repository) { - let dir = tempfile::tempdir().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - let mut config = repo.config().unwrap(); - config.set_str("user.name", "Test").unwrap(); - config.set_str("user.email", "test@test.com").unwrap(); - (dir, repo) + fn next_ts() -> i64 { + COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst) } - fn create_commit(repo: &Repository, dir: &std::path::Path, filename: &str, message: &str) { - let file_path = dir.join(filename); - fs::write(&file_path, format!("content of {filename}")).unwrap(); - let mut index = repo.index().unwrap(); - index.add_path(std::path::Path::new(filename)).unwrap(); - index.write().unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let ts = COMMIT_TIME.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let sig = Signature::new("Test", "test@test.com", &git2::Time::new(ts, 0)).unwrap(); - let parents: Vec = match repo.head() { - Ok(head) => vec![head.peel_to_commit().unwrap()], - Err(_) => vec![], - }; - let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs) - .unwrap(); + fn create_commit(_repo: &crate::git::Repository, dir: &Path, filename: &str, message: &str) { + commit_file( + dir, + filename, + &format!("content of {filename}"), + message, + next_ts(), + ); } - fn create_tag(repo: &Repository, tag_name: &str) { - let head = repo.head().unwrap().peel_to_commit().unwrap(); - repo.tag_lightweight(tag_name, head.as_object(), false) - .unwrap(); + fn create_tag(repo: &crate::git::Repository, tag_name: &str) { + let workdir = repo.workdir().expect("workdir"); + git(workdir, &["tag", tag_name]); } fn setup_single_package(dir: &std::path::Path) {