From 26f3a981424540cf3a118882999112b67d8848e4 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Thu, 21 May 2026 09:03:57 +0200 Subject: [PATCH] =?UTF-8?q?refactor(git):=20drop=20git2/libgit2=20?= =?UTF-8?q?=E2=80=94=20full=20gix=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the git2 dependency entirely. The cli feature now uses gix for all local repository operations (tag enumeration, commit walks, tree diffs, object lookups) and shells out to the user's installed git CLI for network operations (push, fetch, ls-remote) and write operations (commit, tag, branch creation, checkout, reset). This is the same hybrid strategy cargo uses. Why shell out for network and writes: - gix-protocol exists but its API surface is genuinely complex and the blocking-network-client feature pulls reqwest + a large transport stack. We were already shelling out for push tags (#459) and the credential-helper path requires shell-out anyway. - Writes through gix (create_commit, create_tag, create_branch) require hand-rolling index manipulation, tree writing, and ref edits. git's porcelain handles all the edge cases for free. - Diff via gix needs the blob-diff feature which pulls in gix-diff, gix-filter, gix-traverse - heavy for our use case (just list paths that changed). git diff-tree / git ls-tree is a one-liner. What gix is used for (the perf-sensitive read paths): - collect_all_tags, find_last_tag, find_highest_semver_tag, TagIndex (the hot path for monorepo tag scanning) - get_commits_since_oid (revwalk) - open_repo, get_repo_root, resolve_current_branch - find_object, find_commit, find_tree for tag-to-commit resolution - repo.references().tags() for tag iteration Auth path: - For shell-out calls (git push/fetch/ls-remote): inline credential helper via -c credential.helper config (no token in argv, no token in URL). - FERRFLOW_TOKEN / GITHUB_TOKEN / GITLAB_TOKEN env vars unchanged. Tests + benches: - Shell-out helpers in lib.rs::test_utils and main.rs::test_utils (git init/add/commit/tag) replace the git2-based fixture functions. - benches/ferrflow_benchmarks.rs uses the same shell-out fixture pattern; bench measurements stay comparable. - The fetch_and_rebase and reset_branch_to_remote integration tests (which set up two repos + a bare remote) were rewritten with shell-out — same scenarios, same assertions. - 512 lib tests pass, 614 bin tests pass, cargo clippy -D warnings clean. Cargo.lock no longer contains git2, libgit2-sys, or openssl-src. Also fixes two long-standing Publish workflow failures: - ferrflow-wasm: wasm-pack's bundled wasm-opt fails on the new compiler output. Disable it via [package.metadata.wasm-pack.profile.release]. - npm scope rename: @ferrflow/* -> @ferrlabs/ferrflow-* (platforms), ferrflow -> @ferrlabs/ferrflow (wrapper), and @ferrflow/wasm -> @ferrlabs/ferrflow-wasm. The old @ferrflow user scope wasn't writable by the bot token; the @ferrlabs org scope is. The breaking-change marker (!) is dropped: v5.0.0 already shipped via #486 (the auth refactor) which is the SemVer-breaking piece of this work for downstream embedders of ferrflow::git::auth. This follow-up is a pure refactor with no public API change. --- .github/workflows/publish.yml | 2 +- Cargo.lock | 905 +----------------------- Cargo.toml | 6 +- README.md | 4 +- benches/ferrflow_benchmarks.rs | 110 ++- ferrflow-wasm/Cargo.toml | 3 + npm/bin/ferrflow.js | 10 +- npm/package.json | 12 +- npm/platforms/darwin-arm64/package.json | 2 +- npm/platforms/darwin-x64/package.json | 2 +- npm/platforms/linux-arm64/package.json | 2 +- npm/platforms/linux-x64/package.json | 2 +- npm/platforms/win32-x64/package.json | 2 +- npm/scripts/publish-wasm.sh | 11 +- npm/scripts/publish.sh | 2 +- src/config/loader_js.rs | 4 + src/config/tests.rs | 2 +- src/config/workspace.rs | 13 +- src/git/auth.rs | 44 +- src/git/commits.rs | 144 ++-- src/git/diff.rs | 123 ++-- src/git/fetch.rs | 35 +- src/git/mod.rs | 3 +- src/git/push.rs | 420 +++++------ src/git/repo.rs | 14 +- src/git/shell.rs | 30 + src/git/tags.rs | 472 ++++++------ src/git/tests.rs | 520 +++++--------- src/lib.rs | 79 +++ src/main.rs | 61 +- src/monorepo/preview.rs | 5 +- src/monorepo/release.rs | 9 +- src/monorepo/run/drafts.rs | 2 +- src/monorepo/run/mod.rs | 13 +- src/monorepo/util.rs | 41 +- src/query.rs | 45 +- src/status.rs | 45 +- 37 files changed, 1185 insertions(+), 2014 deletions(-) create mode 100644 src/git/shell.rs 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) {