diff --git a/Cargo.lock b/Cargo.lock index 8a78d1c..515a203 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -88,12 +88,24 @@ 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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -135,6 +147,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -153,6 +171,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -224,6 +252,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -291,6 +335,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -319,7 +374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -348,6 +403,12 @@ dependencies = [ "libc", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -366,6 +427,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -470,6 +555,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -508,11 +604,13 @@ dependencies = [ name = "git-vendor" version = "1.0.0-alpha.1" dependencies = [ + "anyhow", "clap", "clap_mangen", "git-set-attr", "gix", "gix-glob", + "gix-quote", "proptest", "rstest", "tempfile", @@ -1256,7 +1354,7 @@ dependencies = [ "bitflags", "gix-path", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1337,13 +1435,16 @@ 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", ] @@ -1456,6 +1557,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[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 = "hash32" version = "0.3.1" @@ -1507,18 +1627,235 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "human_format" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "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-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "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 = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1541,6 +1878,12 @@ 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" @@ -1565,7 +1908,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1594,6 +1937,17 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1621,6 +1975,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1662,6 +2022,40 @@ dependencies = [ "libc", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nonempty" version = "0.12.0" @@ -1689,6 +2083,49 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1724,6 +2161,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1739,6 +2182,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1916,20 +2368,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] -name = "roff" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" - -[[package]] -name = "rstest" -version = "0.23.0" +name = "reqwest" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "futures", - "futures-timer", - "rstest_macros", + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", "rustc_version", ] @@ -1970,7 +2475,40 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2000,12 +2538,44 @@ 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.27" @@ -2081,6 +2651,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.4.4" @@ -2113,6 +2689,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2131,6 +2717,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2142,6 +2734,26 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2152,7 +2764,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2175,6 +2787,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2190,6 +2812,53 @@ 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-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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" @@ -2220,6 +2889,76 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[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.20.0" @@ -2268,12 +3007,42 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" 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" @@ -2299,6 +3068,21 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -2317,6 +3101,61 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2351,6 +3190,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2373,7 +3222,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2388,6 +3237,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2397,6 +3255,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.3" @@ -2494,6 +3416,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2514,6 +3465,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 8c2bfd2..d795038 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,12 @@ unsafe_code = "forbid" missing_docs = "warn" [workspace.dependencies] +anyhow = "1" clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" -gix = { version = "0.83", features = ["merge", "tree-editor", "blocking-network-client", "worktree-mutation"] } +gix = { version = "0.83", features = ["attributes", "merge", "tree-editor", "blocking-network-client", "worktree-mutation", "blocking-http-transport-reqwest-native-tls"] } gix-glob = "0.26.0" +gix-quote = "0.7" tempfile = "3" thiserror = "2" diff --git a/crates/git-vendor/Cargo.toml b/crates/git-vendor/Cargo.toml index 959df98..c7191ff 100644 --- a/crates/git-vendor/Cargo.toml +++ b/crates/git-vendor/Cargo.toml @@ -21,6 +21,7 @@ name = "git-vendor" path = "src/main.rs" [dependencies] +anyhow.workspace = true clap.workspace = true clap_mangen.workspace = true gix.workspace = true @@ -28,6 +29,7 @@ git-set-attr = { version = "0.1.2", path = "../git-set-attr" } tempfile.workspace = true thiserror.workspace = true gix-glob.workspace = true +gix-quote.workspace = true [dev-dependencies] proptest = "1.5.0" diff --git a/crates/git-vendor/src/attr_tests.rs b/crates/git-vendor/src/attr_tests.rs new file mode 100644 index 0000000..ce98ec9 --- /dev/null +++ b/crates/git-vendor/src/attr_tests.rs @@ -0,0 +1,47 @@ +use rstest::rstest; + +use super::{check_attr_pattern, split_attr_line}; + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"src/lib.rs")] +#[case(b"deep/nested/path.txt")] +// `#`, `!`, and glob metacharacters are escaped on write, not rejected. +#[case(b"#readme")] +#[case(b"!bang")] +#[case(b"a*.txt")] +fn check_plain_path_ok(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_ok()); +} + +#[rstest] +#[case(b"vendor/a b.txt")] +#[case(b"say \"hi\"")] +#[case(b"a\tb")] +#[case(b"a\x01b")] +#[case(b"a\x7fb")] +fn check_path_with_special_chars_errors(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_err()); +} + +#[rstest] +#[case(b"vendor/a.txt vendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"vendor/a.txt\tvendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"\"vendor/a b.txt\" vendor=mylib", b"vendor/a b.txt", b"vendor=mylib")] +#[case(b"\"say \\\"hi\\\"\" vendor=mylib", b"say \"hi\"", b"vendor=mylib")] +fn split_line(#[case] line: &[u8], #[case] pattern: &[u8], #[case] attr: &[u8]) { + let (p, a) = split_attr_line(line).unwrap(); + assert_eq!(p.as_ref(), pattern); + assert_eq!(a, attr); +} + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"")] +#[case(b"# comment line")] +#[case(b"# vendor=mylib")] +#[case(b" ")] +#[case(b" vendor/a.txt attr")] +fn split_no_attr_returns_none(#[case] line: &[u8]) { + assert!(split_attr_line(line).is_none()); +} diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs new file mode 100644 index 0000000..29e248a --- /dev/null +++ b/crates/git-vendor/src/cli.rs @@ -0,0 +1,118 @@ +//! Command-line interface shape for `git-vendor`. + +use clap::{Parser, Subcommand}; + +/// Manage vendored external repository content. +#[derive(Parser)] +#[command(name = "git-vendor", bin_name = "git vendor")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Add a new vendor dependency and integrate it into the current branch. + /// + /// Fetches the upstream ref and three-way merges it into the working + /// tree, staging the result for review. Run `git commit` to complete, + /// mirroring `git submodule add` (which also never commits on your + /// behalf). + Add { + /// Remote URL of the upstream repository. + url: String, + + /// Unique name for this vendor (used in `.gitvendors` and `.gitattributes`). + /// Defaults to the last path component of the URL, stripped of `.git`. + name: Option, + + /// Branch, tag, or SHA to track on the upstream (defaults to `HEAD`). + #[arg(long = "ref", value_name = "REF")] + ref_name: Option, + + /// Destination directory for vendored files (defaults to `vendor//`). + /// Ignored when `--pattern` is also given. + #[arg(long, value_name = "DIR")] + prefix: Option, + + /// File pattern used to filter the upstream vendor content with optional + /// remapping into the working copy, e.g. `src/**:vendor/lib/`. May be + /// repeated to select multiple paths. + #[arg(long = "pattern", short = 'p', value_name = "GLOB[:DEST]")] + patterns: Vec, + + /// Record upstream history as a parentless squash commit instead of a + /// full merge. + #[arg(long)] + squash: bool, + + /// Show what would be fetched and merged without changing the working + /// tree, index, or config. Upstream is still fetched into + /// `refs/vendor/` so there is something to report on. + #[arg(long)] + dry_run: bool, + + /// Commit message (defaults to `vendor: add `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + }, + + /// Fetch and integrate upstream updates for one or all vendors. + /// + /// Equivalent to `git subtree pull` or `git submodule update --remote`. + /// Stages the result for review; run `git commit` to complete, mirroring + /// `git submodule` (which never commits on your behalf). + Update { + /// Vendor name to update; updates all configured vendors if omitted. + name: Option, + + /// Commit message (defaults to `vendor: update `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + + /// Allow integrating an upstream that was force-pushed (rewound + /// history). Without this flag, a force-push is reported as an error. + #[arg(long)] + force: bool, + + /// Rebuild from the recorded upstream base instead of fetching. Use + /// after editing a vendor's `pattern` entries to move or refilter its + /// files. Local modifications to vendored files would be discarded, + /// so this refuses to proceed on a modified vendor unless `--force` + /// is also given. + #[arg(long)] + no_fetch: bool, + + /// Show what would be fetched and merged without changing the working + /// tree, index, or config. Upstream is still fetched into + /// `refs/vendor/` so there is something to report on. + #[arg(long)] + dry_run: bool, + }, + + /// Show synchronization status for one or all vendors. + Status { + /// Vendor name to check; checks all configured vendors if omitted. + name: Option, + + /// Fetch from upstream before reporting status. + #[arg(long, short = 'f')] + fetch: bool, + }, + + /// Remove a vendor dependency and its files from the working tree. + #[command(visible_alias = "rm")] + Remove { + /// Vendor name to remove. + name: String, + + /// Remove the config entry and `.gitattributes` tracking but leave the + /// vendored files in the working tree. + #[arg(long)] + keep_files: bool, + }, + + /// List all configured vendor dependencies. + #[command(visible_alias = "ls")] + List, +} diff --git a/crates/git-vendor/src/error.rs b/crates/git-vendor/src/error.rs index 1fba694..6c8a3a6 100644 --- a/crates/git-vendor/src/error.rs +++ b/crates/git-vendor/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { Fetch(String), #[error("merge conflict: {0}")] Conflict(String), + #[error("path requires quoting in .gitattributes: {0}")] + InvalidPath(String), #[error(transparent)] Gix(Box), } diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index beb9648..4fbb189 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -1,229 +1,810 @@ -//! Working-copy projection layer: the side-effecting half of vendoring. -//! -//! Where [`VendorRepository`](crate::VendorRepository) is a pure -//! object-database algebra, every method here writes the index and working -//! tree (including tracked files like `.gitattributes`). It is the sole owner -//! of the one working copy and its ambient `HEAD`/index state, kept distinct -//! from the pure object-database operations. - -use gix::bstr::{BStr, ByteSlice as _}; - -use crate::{Error, VendorEntry, VendorMerge, VendorRepository, VendorWorktree}; - -impl VendorWorktree for gix::Repository { - fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error> { - // SAFETY - // This is the trust boundary where upstream content (carried verbatim - // through `upstream_tree`, including symlink and gitlink modes, - // mirroring git-subtree/submodule) reaches the working copy. Like - // core git's `verify_path`/checkout, projection MUST refuse to write - // through a symlinked leading path and reject `..`/absolute - // components — use gix-worktree's checked checkout, never naive - // `std::fs` writes. See the `upstream_tree` adversarial review (#5). - // NOTE - // Path-traversal safety (e.g. `../` components, symlinked leading - // paths) is delegated to gix and is not covered by automated tests. - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - - let head_id = self.head_commit().ok().map(|c| c.id().detach()); - - let old_paths: std::collections::BTreeSet = head_id - .and_then(|id| crate::resolve_vendor_paths(self, entry, id).ok()) - .into_iter() - .flatten() - .collect(); - - let mut vendor_index = self.index_from_tree(&tree)?; - - let new_paths: std::collections::BTreeSet = vendor_index - .entries() - .iter() - .map(|e| e.path(&vendor_index).to_owned()) - .collect(); - - let opts = self - .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) - .map_err(|e| Error::Gix(Box::new(e)))?; - let progress = gix::progress::Discard; - gix::worktree::state::checkout( - &mut vendor_index, - workdir, - self.objects.clone().into_arc().map_err(Error::Io)?, - &progress, - &progress, - &gix::interrupt::IS_INTERRUPTED, - gix::worktree::state::checkout::Options { - overwrite_existing: true, - ..opts - }, - ) - .map_err(|e| Error::Gix(Box::new(e)))?; - - for removed in old_paths.difference(&new_paths) { - let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); - if abs.symlink_metadata().is_ok() { - std::fs::remove_file(&abs)?; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use git_vendor::{ + PatternMapping, VendorConfig, VendorEntry, VendorMode, VendorName, VendorRepository, + VendorStatus, VendorWorktree, +}; + +use crate::cli; + +type Result = std::result::Result; + +/// Errors surfaced by the `git-vendor` executor. +/// +/// Each application-level failure gets its own variant so `main` (and any other +/// caller) can match on it rather than string-sniff — in particular +/// [`Error::Conflict`], which signals that the working tree has been staged +/// with conflict markers and the process should exit non-zero *without* +/// printing a further message. Errors from the vendoring library and from +/// filesystem I/O are wrapped transparently; lower-level `gix` plumbing errors +/// are boxed into [`Error::Gix`], mirroring [`git_vendor::Error`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The current directory is not inside a git repository. + #[error(transparent)] + Discover(#[from] Box), + + /// The repository has no working copy (it is bare). + #[error("not a working-copy repository")] + NotWorkingCopy, + + /// A vendor with the requested name already exists. + #[error("vendor {0:?} already exists; use `git vendor update {0}` or remove it first")] + VendorExists(String), + + /// No vendor with the requested name is configured. + #[error("no vendor named {0:?}")] + NoSuchVendor(String), + + /// No name was given and none could be derived from the URL. + #[error("cannot derive a vendor name from URL {0:?}; pass a name explicitly")] + UndeterminableName(String), + + /// `.gitattributes` carries an unresolved conflict that blocks the update. + #[error("`.gitattributes` has an unresolved conflict; resolve it before updating")] + GitattributesConflict, + + /// Reading `HEAD` failed. + #[error("HEAD: {0}")] + Head(#[source] Box), + + /// The merge left unresolved conflicts. The working tree has already been + /// staged and actionable guidance printed to stderr; `main` exits non-zero + /// without rendering this error. + #[error("unresolved merge conflict")] + Conflict, + + /// An error from the vendoring library. + #[error(transparent)] + Vendor(#[from] git_vendor::Error), + + /// A filesystem I/O error. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// A lower-level `gix` error with no more specific variant. + #[error(transparent)] + Gix(Box), +} + +macro_rules! impl_gix_from { + ($($ty:path),* $(,)?) => { + $( + impl From<$ty> for Error { + fn from(e: $ty) -> Self { + Error::Gix(Box::new(e)) + } } + )* + }; +} + +impl_gix_from! { + gix::repository::index_from_tree::Error, + gix::object::find::existing::with_conversion::Error, + gix::object::commit::Error, + gix::worktree::open_index::Error, + gix::reference::find::Error, + gix::reference::edit::Error, + gix::index::file::write::Error, +} + +pub struct Io { + pub out: Box, + pub err: Box, +} + +impl Io { + pub fn stdio() -> Self { + Io { + out: Box::new(std::io::stdout()), + err: Box::new(std::io::stderr()), + } + } +} + +pub struct Executor(pub gix::Repository); + +impl Executor { + pub fn discover() -> Result { + Ok(Self(gix::discover(".").map_err(Box::new)?)) + } + + pub fn run(&self, cli: cli::Cli, io: &mut Io) -> Result<()> { + match cli.command { + cli::Command::Add { + url, + name, + ref_name, + prefix, + patterns, + squash, + dry_run, + message, + } => self.add( + name, url, ref_name, prefix, patterns, squash, dry_run, message, io, + ), + cli::Command::Update { + name, + message, + force, + no_fetch, + dry_run, + } => self.update(name, message, force, no_fetch, dry_run, io), + cli::Command::Status { name, fetch } => self.status(name, fetch, io), + cli::Command::Remove { name, keep_files } => self.remove(name, keep_files, io), + cli::Command::List => self.list(io), } + } - // Overlay the vendor tree onto the full HEAD tree and rebuild the index - // from the result. An unborn HEAD has no base commit, so the vendor tree - // is itself the whole tree. - let full_tree = match head_id { - Some(id) => self.vendor_overlay(entry, id, tree)?, - None => tree, + #[allow(clippy::too_many_arguments)] + fn add( + &self, + name: Option, + url: String, + ref_name: Option, + prefix: Option, + patterns: Vec, + squash: bool, + dry_run: bool, + message: Option, + io: &mut Io, + ) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let name = match name { + Some(n) => n, + None => name_from_url(&url).ok_or_else(|| Error::UndeterminableName(url.clone()))?, }; - let mut main_index = self.index_from_tree(&full_tree)?; - main_index.set_path(self.git_dir().join("index")); - - // `index_from_tree` zeroes stat data; carry over the stats checkout just - // populated on the vendor entries so `git status` need not re-hash them. - let vendor_stats: std::collections::HashMap = - vendor_index - .entries() - .iter() - .map(|e| (e.path(&vendor_index).to_owned(), e.stat)) - .collect(); - for (e, path) in main_index.entries_mut_with_paths() { - if let Some(stat) = vendor_stats.get(path) { - e.stat = *stat; - } + let vendor_name = VendorName::new(&name)?; + if config.get(vendor_name.as_str())?.is_some() { + return Err(Error::VendorExists(name.clone())); + } + let mode = if squash { + VendorMode::Squash + } else { + VendorMode::default() + }; + let patterns: Vec = if patterns.is_empty() { + let dest = prefix.map_or_else( + || format!("vendor/{name}/"), + |p| { + if p.ends_with('/') { p } else { format!("{p}/") } + }, + ); + vec![format!("**:{dest}")] + } else { + patterns + }; + let mut entry = VendorEntry { + name: vendor_name, + url, + ref_name, + base: None, + patterns: patterns.iter().map(|p| PatternMapping::parse(p)).collect(), + mode, + }; + + // Fetch before touching config — a failed fetch leaves no side effects. + writeln!(io.err, "Fetching {name}…")?; + let upstream = repo.fetch_vendor(&entry)?; + + if dry_run { + writeln!(io.err, "Would add vendor {name} at {upstream}.")?; + return Ok(()); } - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: add {name}")); + + match head_oid { + Some(ours) => { + let merge = repo.merge_vendor(&entry, ours, upstream)?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &[], + &msg, + false, + io, + )?; + if conflicted { + return Err(Error::Conflict); + } + writeln!(io.err, "Staged; run `git commit` to complete.")?; + } + None => { + // An unborn HEAD has no merge to seal, so the tool writes no + // MERGE_MSG and never commits: a `-m` message has nowhere to go. + if message.is_some() { + writeln!( + io.err, + "note: --message is ignored on an unborn HEAD; pass it to your own \ + `git commit`." + )?; + } + let tree = repo.upstream_tree(&entry, upstream)?; + let new_paths = tree_paths(repo, tree)?; + let path_refs: Vec<&gix::bstr::BStr> = + new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + repo.checkout_vendor(&entry, tree)?; + repo.track_vendor(&entry, &path_refs)?; + + entry.base = Some(upstream); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Staged; run `git commit` to complete.")?; + } + } Ok(()) } - fn checkout_vendor_conflicted( + fn update( &self, - entry: &VendorEntry, - merge: &VendorMerge, - ) -> Result<(), Error> { - use gix::bstr::ByteSlice as _; - - // Write the result tree (with conflict markers) to the working copy. - self.checkout_vendor(entry, merge.result_tree)?; - - // Reopen the index we just wrote so we can splice in unmerged stages. - let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; - main_index.set_path(self.git_dir().join("index")); - - for conflict in &merge.conflicts { - let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); - let path_bstr = path_bytes.as_bstr(); - - // Remove the stage-0 entry for this path. - main_index.remove_entries(|_, p, _| p == path_bstr); - - // Insert stage 1/2/3 entries for each present stage. - for (stage_idx, stage_variant) in [ - (0usize, gix::index::entry::Stage::Base), - (1usize, gix::index::entry::Stage::Ours), - (2usize, gix::index::entry::Stage::Theirs), - ] { - if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { - let mode = gix::index::entry::Mode::from(tree_mode); - let flags = gix::index::entry::Flags::from_stage(stage_variant); - main_index.dangerously_push_entry( - gix::index::entry::Stat::default(), - oid, - flags, - mode, - path_bstr, - ); + name: Option, + message: Option, + force: bool, + no_fetch: bool, + dry_run: bool, + io: &mut Io, + ) -> Result<()> { + if no_fetch { + return self.update_no_fetch(name, force, io); + } + + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| Error::Head(Box::new(e)))?; + + let total = entries.len(); + for (i, mut entry) in entries.into_iter().enumerate() { + let n = entry.name.as_str().to_owned(); + writeln!(io.err, "Fetching {n}…")?; + let upstream = repo.fetch_vendor(&entry)?; + + let status = repo.vendor_status(&entry)?; + match status { + VendorStatus::UpToDate => { + writeln!(io.err, "{n}: already up to date")?; + continue; } + VendorStatus::ForcePushed { .. } if !force => { + writeln!( + io.err, + "{n}: upstream was force-pushed; re-run with --force to accept" + )?; + continue; + } + _ => {} } - } - main_index.sort_entries(); - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; + if dry_run { + writeln!(io.err, "Would update {n} to {upstream}.")?; + continue; + } + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: update {n}")); + + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; + let merge = repo.merge_vendor(&entry, head_oid, upstream)?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &old_paths, + &msg, + true, + io, + )?; + if conflicted { + return Err(Error::Conflict); + } + writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; + + // `prepare_merge` overwrites MERGE_HEAD rather than accumulating an + // octopus merge, so a second vendor's pending merge in the same run + // would silently clobber this one's. Stop here; re-running `update` + // after the commit picks up the rest. + let remaining = total - i - 1; + if remaining > 0 { + writeln!( + io.err, + "Stopped after {n}; {remaining} more vendor(s) not yet processed — \ + re-run `git vendor update` after committing this merge." + )?; + } + break; + } Ok(()) } - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + /// Validate and stage a merge result shared by `add` and `update`: checks + /// that upstream paths are trackable, checks out the merged tree + /// (conflicted or clean), reconciles `.gitattributes`, records the new + /// base, and stages `.gitvendors`. Returns `Ok(true)` if the merge left + /// conflicts, having already printed the conflict message — the caller + /// should return `Err(Error::Conflict)`. `require_reconcile` controls + /// whether an unresolved `.gitattributes` conflict on a clean merge is + /// itself treated as an error (true for `update`, which has old paths to + /// reconcile against; false for `add`'s first-merge case, which has none). + #[allow(clippy::too_many_arguments)] + fn apply_merge( + &self, + config: &mut VendorConfig, + cfg_path: &Path, + entry: &mut VendorEntry, + merge: &git_vendor::VendorMerge, + old_paths: &[gix::bstr::BString], + msg: &str, + require_reconcile: bool, + io: &mut Io, + ) -> Result { + let repo = &self.0; + let new_paths = tree_paths(repo, merge.result_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(entry, merge)?; + reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + writeln!(io.err, "{}: conflict in {}", entry.name, paths.join(", "))?; + writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + return Ok(true); + } - let existing: Vec = if gitattributes.exists() { - std::fs::read(&gitattributes)? - } else { - Vec::new() - }; + repo.checkout_vendor(entry, merge.result_tree)?; + let reconciled = reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + if require_reconcile { + reconciled + .ok_or(Error::GitattributesConflict)?; + } - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); - - let already_tracked: std::collections::HashSet<&[u8]> = existing - .lines() - .filter_map(|line| { - let i = line.iter().position(|&b| b == b' ')?; - let attr = line[i + 1..].trim(); - if attr == attr_bytes { - Some(&line[..i]) - } else { - None - } - }) - .collect(); - - let mut out = existing.clone(); - if !out.is_empty() && out.last() != Some(&b'\n') { - out.push(b'\n'); - } - for path in paths { - if !already_tracked.contains(path.as_bytes()) { - out.extend_from_slice(path.as_bytes()); - out.push(b' '); - out.extend_from_slice(attr_bytes); - out.push(b'\n'); - } + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + Ok(false) + } + + /// Rebuild vendored files from `.gitvendors` without fetching (`update + /// --no-fetch`). Use after editing a vendor's `pattern` entries to move + /// or refilter its files. Refuses a modified vendor unless `force`. + fn update_no_fetch(&self, name: Option, force: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); } - if out != existing { - std::fs::write(&gitattributes, &out)?; + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| Error::Head(Box::new(e)))?; + + // Patterns as last committed, for the local-modification check: a vendor + // whose ours tree differs from the pristine upstream tree of its recorded + // base carries patches that re-materializing would discard. + let old_config = config_at(repo, head_oid)?; + + // Stage the user's on-disk `.gitvendors` verbatim, and only when a + // vendor is actually rebuilt. Re-serializing it here would normalize + // the file's formatting even on a run where every vendor is skipped. + let cfg_bytes = std::fs::read(&cfg_path).unwrap_or_default(); + + for entry in entries { + let n = entry.name.as_str().to_owned(); + let Some(base) = entry.base else { + writeln!( + io.err, + "{n}: no recorded base; run `git vendor update {n}` first" + )?; + continue; + }; + + let modified = locally_modified_paths(repo, old_config.as_ref(), &entry, head_oid)?; + if !modified.is_empty() && !force { + writeln!( + io.err, + "{n}: vendored files have local modifications ({}); \ + re-run with --force to discard them", + modified.join(", ") + )?; + continue; + } + + let new_tree = repo.upstream_tree(&entry, base)?; + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; + let new_paths = tree_paths(repo, new_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + repo.checkout_vendor(&entry, new_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + .ok_or(Error::GitattributesConflict)?; + stage_gitvendors(repo, &cfg_bytes)?; + + writeln!( + io.err, + "Updated {n}. Run `git commit` to record the change." + )?; } Ok(()) } - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + fn status(&self, name: Option, fetch: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; - if !gitattributes.exists() { + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; return Ok(()); } - let existing: Vec = std::fs::read(&gitattributes)?; - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); + for entry in &entries { + if fetch { + repo.fetch_vendor(entry)?; + } + let status = repo.vendor_status(entry)?; + let label = match &status { + VendorStatus::NotFetched => "not fetched".to_owned(), + VendorStatus::UpToDate => "up to date".to_owned(), + VendorStatus::UpdateAvailable { upstream } => { + format!("update available ({})", upstream.to_hex()) + } + VendorStatus::ForcePushed { upstream } => { + format!("force-pushed upstream ({})", upstream.to_hex()) + } + }; + writeln!(io.out, "{}\t{}\t{label}", entry.name, entry.url)?; + } - let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + Ok(()) + } - let mut filtered: Vec = Vec::with_capacity(existing.len()); - for line in existing.lines() { - let keep = if let Some(i) = line.iter().position(|&b| b == b' ') { - let attr = line[i + 1..].trim(); - !(attr == attr_bytes && remove.contains(&line[..i])) - } else { - true - }; - if keep { - filtered.extend_from_slice(line); - filtered.push(b'\n'); + fn remove(&self, name: String, keep_files: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let entry = require_entry(&config, &name)?; + + // Resolve the vendor's paths from HEAD *and* the staged index, unioned. + // HEAD alone misses a vendor added but not yet committed (its tracking + // lives only in the staged `.gitattributes`, not any committed tree), + // which would leave its files on disk, in the index, and in + // `.gitattributes` while reporting success. + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + let mut path_set: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + if let Some(oid) = head_oid { + path_set.extend(repo.vendor_paths(&entry, oid)?); + } + path_set.extend(git_vendor::resolve_vendor_paths_uncommitted(repo, &entry)?); + let paths: Vec = path_set.into_iter().collect(); + + { + use gix::bstr::ByteSlice as _; + let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); + + if !keep_files { + let workdir = repo.workdir().ok_or(Error::NotWorkingCopy)?; + for p in &paths { + let abs = workdir.join(gix::path::from_bstr(p).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + remove_empty_ancestors(&abs, workdir); + } + } + + repo.untrack_vendor(&entry, &path_refs)?; + + if !keep_files { + let mut index = repo.open_index()?; + for p in &path_refs { + let pb = p.as_bytes(); + index.remove_entries(|_, path, _| path == pb.as_bstr()); + } + index.sort_entries(); + index.write(gix::index::write::Options::default())?; } } - if filtered != existing { - std::fs::write(&gitattributes, &filtered)?; + // Drop the private `refs/vendor/` tracking ref so a removed + // vendor doesn't leak a ref (and a re-add starts from a clean slate). + if let Some(reference) = repo.try_find_reference(&entry.vendor_ref())? { + reference.delete()?; + } + + config.remove(&name)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Removed vendor {name}.")?; + Ok(()) + } + + fn list(&self, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + let entries = config.entries()?; + + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + for entry in &entries { + let ref_label = entry.ref_name.as_deref().unwrap_or("HEAD"); + let mode_label = entry.mode.as_str(); + writeln!( + io.out, + "{}\t{}\t{ref_label}\t{mode_label}", + entry.name, entry.url + )?; } Ok(()) } } + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn config_path(repo: &gix::Repository) -> Result { + let workdir = repo.workdir().ok_or(Error::NotWorkingCopy)?; + Ok(workdir.join(".gitvendors")) +} + +fn load_config(path: &Path) -> Result { + if path.exists() { + Ok(VendorConfig::open(path)?) + } else { + Ok(VendorConfig::parse("")?) + } +} + +/// Write `config` to `path` and return the serialized bytes for blob staging. +fn save_config(config: &VendorConfig, path: &Path) -> Result { + let s = config.to_string(); + std::fs::write(path, &s)?; + Ok(s) +} + +fn require_entry(config: &VendorConfig, name: &str) -> Result { + config + .get(name)? + .ok_or_else(|| Error::NoSuchVendor(name.to_owned())) +} + +/// Remove `path`'s parent directory and each ancestor above it, as long as +/// they're empty and still inside `workdir`. Stops at the first non-empty or +/// out-of-bounds directory. +fn remove_empty_ancestors(path: &Path, workdir: &Path) { + let mut dir = path.parent(); + while let Some(d) = dir { + if d == workdir || !d.starts_with(workdir) { + break; + } + if std::fs::remove_dir(d).is_err() { + break; + } + dir = d.parent(); + } +} + +/// Resolve `name` to a single-entry list, or all configured vendors if omitted. +fn resolve_entries(config: &VendorConfig, name: Option<&str>) -> Result> { + match name { + Some(n) => Ok(vec![require_entry(config, n)?]), + None => Ok(config.entries()?), + } +} + +/// Local-modification guard for `update --no-fetch`: compares `entry`'s +/// current working tree against the pristine upstream tree of its last +/// recorded base, returning the sorted list of differing paths (empty if +/// unmodified or if there's no prior recorded base to compare against). +fn locally_modified_paths( + repo: &gix::Repository, + old_config: Option<&VendorConfig>, + entry: &VendorEntry, + head_oid: gix::ObjectId, +) -> Result> { + let pristine = old_config + .and_then(|c| c.get(entry.name.as_str()).ok().flatten()) + .and_then(|old| old.base.map(|b| (old, b))) + .map(|(old, b)| repo.upstream_tree(&old, b)) + .transpose()?; + let Some(pristine) = pristine else { + return Ok(Vec::new()); + }; + + let ours = repo.ours_tree(entry, head_oid)?; + if ours == pristine { + return Ok(Vec::new()); + } + + let pristine_blobs = tree_blobs(repo, pristine)?; + let our_blobs = tree_blobs(repo, ours)?; + let mut modified: Vec = our_blobs + .iter() + .filter(|(p, entry)| pristine_blobs.get(*p) != Some(entry)) + .map(|(p, _)| p.to_string()) + .collect(); + modified.extend( + pristine_blobs + .keys() + .filter(|p| !our_blobs.contains_key(*p)) + .map(|p| p.to_string()), + ); + modified.sort(); + Ok(modified) +} + +fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| e.path(&index).into()) + .collect()) +} + +/// Map each blob path in `tree_id` to its `(oid, mode)`. The mode is included +/// so an executable-bit-only change (same content, different mode) still reads +/// as a local modification. +fn tree_blobs( + repo: &gix::Repository, + tree_id: gix::ObjectId, +) -> Result> +{ + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| (e.path(&index).into(), (e.id, e.mode))) + .collect()) +} + +fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result> { + let tree = repo.find_commit(commit)?.tree()?; + let Some(entry) = tree + .lookup_entry_by_path(".gitvendors") + .map_err(|e| Error::Gix(Box::new(e)))? + else { + return Ok(None); + }; + let blob = entry.object().map_err(|e| Error::Gix(Box::new(e)))?; + // Error on invalid UTF-8 rather than lossily mangling a `.gitvendors` whose + // bytes we'd otherwise silently corrupt. + Ok(Some(VendorConfig::open_from_bytes(&blob.data)?)) +} + +/// Whether `path` currently has any unmerged (non-zero-stage) entry in the +/// index — i.e. it is itself part of an unresolved conflict. +fn has_unmerged_stages(repo: &gix::Repository, path: &gix::bstr::BStr) -> Result { + let index = repo.open_index()?; + Ok(index.entries().iter().any(|e| { + e.path(&index) == path && e.flags.stage() != gix::index::entry::Stage::Unconflicted + })) +} + +/// Update `.gitattributes` tracking for a vendor's path set. +/// +/// A vendor whose destination pattern maps onto `.gitattributes` itself can +/// leave that path with unmerged stages after `checkout_vendor_conflicted` +/// spliced in a genuine merge conflict on it. Writing new tracking lines in +/// that case would silently collapse the conflict to a single resolved +/// stage-0 entry, so `git commit` would succeed despite the unresolved +/// conflict. When that happens, this leaves the unmerged stages untouched and +/// returns `None` instead. +fn reconcile_tracked_paths( + repo: &gix::Repository, + entry: &VendorEntry, + old_paths: &[gix::bstr::BString], + new_paths: &[gix::bstr::BString], + io: &mut Io, +) -> Result> { + use gix::bstr::{BStr, ByteSlice as _}; + + if has_unmerged_stages(repo, b".gitattributes".as_bstr())? { + writeln!( + io.err, + "{}: .gitattributes itself is part of this conflict; resolve it \ + manually, including the vendor={} tracking lines, before committing", + entry.name, entry.name, + )?; + return Ok(None); + } + + let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + let attrs_oid = repo.track_vendor(entry, &track)?; + + let new_set: std::collections::HashSet<&[u8]> = + new_paths.iter().map(|b| b.as_slice()).collect(); + let removed: Vec<&BStr> = old_paths + .iter() + .filter(|b| !new_set.contains(b.as_slice())) + .map(|b| b.as_ref()) + .collect(); + if !removed.is_empty() + && let Some(oid) = repo.untrack_vendor(entry, &removed)? + { + return Ok(Some(oid)); + } + Ok(Some(attrs_oid)) +} + +/// Write `content` as a blob and upsert the `.gitvendors` index entry to point +/// at it, returning the blob OID. +fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result { + use gix::bstr::ByteSlice as _; + Ok(git_vendor::stage_file( + repo, + b".gitvendors".as_bstr(), + content, + )?) +} + +fn name_from_url(url: &str) -> Option { + let stem = url + .trim_end_matches('/') + .rsplit(['/', ':']) + .find(|s| !s.is_empty())?; + let stem = stem + .strip_suffix(".git") + .or_else(|| stem.strip_suffix(".bundle")) + .unwrap_or(stem); + if stem.is_empty() { + None + } else { + Some(stem.to_owned()) + } +} + +#[cfg(test)] +#[path = "exe_tests.rs"] +mod tests; diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs new file mode 100644 index 0000000..c0e64f2 --- /dev/null +++ b/crates/git-vendor/src/exe_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use std::path::Path; + +fn git(args: &[&str], dir: &Path) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git"); + assert!( + output.status.success(), + "git {args:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn test_entry() -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: Vec::new(), + mode: VendorMode::Merge, + } +} + +/// `reconcile_tracked_paths` must not collapse a genuine unresolved merge +/// conflict on `.gitattributes` itself into a single resolved stage-0 +/// entry: doing so would let `git commit` silently record the +/// conflict-marker text as if it were normal content. Regression: the +/// function always called `track_vendor`, which reads and rewrites +/// `.gitattributes`, then `stage_gitattributes` unconditionally replaced +/// whatever stages were there with one stage-0 entry. +#[test] +fn skips_write_when_gitattributes_itself_is_conflicted() { + use git_vendor::{ConflictStages, VendorMerge}; + + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join(".gitattributes"), "* text=auto\n").unwrap(); + git(&["add", "."], dir.path()); + git(&["commit", "-q", "-m", "init"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let entry = test_entry(); + + let base_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\n", + }) + .expect("write base blob") + .detach(); + let ours_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\nours=1\n", + }) + .expect("write ours blob") + .detach(); + let theirs_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\ntheirs=1\n", + }) + .expect("write theirs blob") + .detach(); + let conflict_marker_blob = repo + .write_object(gix::objs::BlobRef { + data: b"<<<<<<< ours\nours=1\n=======\ntheirs=1\n>>>>>>> theirs\n", + }) + .expect("write conflict-marker blob") + .detach(); + + let blob_mode = gix::objs::tree::EntryMode::from(gix::objs::tree::EntryKind::Blob); + let head_tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + let mut editor = repo + .find_tree(head_tree) + .expect("find tree") + .edit() + .expect("edit"); + editor + .upsert( + ".gitattributes", + gix::objs::tree::EntryKind::Blob, + conflict_marker_blob, + ) + .expect("upsert conflicted .gitattributes"); + let result_tree = editor.write().expect("write tree").detach(); + + let merge = VendorMerge { + upstream_commit: repo.head_commit().expect("head").id().detach(), + ancestor_tree: None, + result_tree, + conflicts: vec![ConflictStages { + path: ".gitattributes".to_owned(), + stages: [ + Some((blob_mode, base_blob)), + Some((blob_mode, ours_blob)), + Some((blob_mode, theirs_blob)), + ], + }], + }; + + repo.checkout_vendor_conflicted(&entry, &merge) + .expect("checkout_vendor_conflicted"); + + let new_paths = tree_paths(&repo, merge.result_tree).expect("tree_paths"); + let mut io = Io { + out: Box::new(Vec::new()), + err: Box::new(Vec::new()), + }; + let result = reconcile_tracked_paths(&repo, &entry, &[], &new_paths, &mut io) + .expect("reconcile_tracked_paths"); + assert!( + result.is_none(), + "must skip and return None when .gitattributes itself is conflicted", + ); + + let index = repo.open_index().expect("open_index"); + use gix::bstr::ByteSlice as _; + let stages: Vec<_> = index + .entries() + .iter() + .filter(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.flags.stage()) + .collect(); + assert_eq!( + stages.len(), + 3, + "all three unmerged stages must survive, got {stages:?}", + ); + assert!( + stages + .iter() + .all(|s| *s != gix::index::entry::Stage::Unconflicted), + "no stage should have been collapsed to stage 0, got {stages:?}", + ); +} diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index 84ff3fd..5195fce 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -3,10 +3,8 @@ mod error; mod vendor; -pub mod exe; - pub use error::Error; -use gix::bstr::ByteSlice as _; +use gix::bstr::{BStr, ByteSlice as _}; use gix::remote::fetch::{Status, refs::update::Mode}; pub use vendor::{ ConflictStages, PatternMapping, VendorConfig, VendorEntry, VendorMerge, VendorMode, VendorName, @@ -36,9 +34,9 @@ fn is_unsafe_local_path(path: &gix::bstr::BStr) -> bool { impl VendorRepository for gix::Repository { /// Fetches `entry.tracking_ref()` from `entry.url` into `refs/vendor/` - /// and returns the *peeled* tip OID. When the tracked ref is an annotated - /// tag, the returned id is the tag's ultimate target, not the tag object - /// stored at `refs/vendor/`. + /// and returns the *peeled* tip OID. `refs/vendor/` is always written + /// to point directly at that same peeled OID, even when the tracked ref is + /// an annotated tag — the tag object itself is never stored there. /// /// If the local ref is already up to date, the ref tip's existing object hash /// is returned. @@ -57,14 +55,7 @@ impl VendorRepository for gix::Repository { | gix::url::Scheme::Http | gix::url::Scheme::Ssh | gix::url::Scheme::Git => {} - gix::url::Scheme::File => { - if !self.is_bare() { - return Err(Error::InvalidUrl(format!( - "{}: refusing transport `{:?}`; local transports are not yet supported", - entry.url, url.scheme - ))); - } - } + gix::url::Scheme::File => {} ref other => { return Err(Error::InvalidUrl(format!( "{}: refusing transport `{other:?}`; plug-in transports are not supported", @@ -128,8 +119,61 @@ impl VendorRepository for gix::Repository { } } - let mut reference = self.find_reference(&entry.vendor_ref())?; - let id = reference.peel_to_id()?.detach(); + // Read the upstream OID from the refmap rather than by re-reading the + // local vendor ref. When the remote advertises HEAD as a symbolic ref + // (e.g. `HEAD → refs/heads/main`) and the local repo happens to have a + // branch of the same name, gix writes `refs/vendor/` as a symref + // pointing to that local branch; `peel_to_id()` would then silently + // return the *local* HEAD instead of the upstream tip. The refmap + // carries the actual upstream OID directly before any local ref + // resolution, so keying on it sidesteps the bug entirely. + // Upstream: https://github.com/GitoxideLabs/gitoxide/issues/2613 + let vendor_ref = entry.vendor_ref(); + let id = outcome + .ref_map + .mappings + .iter() + .find(|m| { + m.local + .as_deref() + .map(|l| l == vendor_ref.as_bytes()) + .unwrap_or(false) + }) + .and_then(|m| m.remote.peeled_id()) + .map(gix::oid::to_owned) + .ok_or_else(|| { + Error::Fetch(format!( + "remote has no ref matching `{}` for vendor `{}`", + entry.tracking_ref(), + entry.name + )) + })?; + + // `peeled_id()` only returns a peeled value when the ref advertisement + // carried one; fetching an annotated tag by its own object SHA + // (`Source::ObjectId`) has no such advertisement, so `id` may still be + // the tag object itself. Peel explicitly so callers always get a commit. + let id = self + .find_object(id) + .map_err(|e| Error::Gix(Box::new(e)))? + .peel_to_commit() + .map_err(|e| Error::Gix(Box::new(e)))? + .id() + .detach(); + + // Force `refs/vendor/` to point directly at `id`, overwriting + // whatever gix wrote for it (see the gix#2613 note above: it may be a + // symref into the local branch namespace rather than a direct ref to + // the fetched commit). This keeps `vendor_tip`/`vendor_status`, which + // read the ref directly, from resolving the corrupted symref. + self.reference( + entry.vendor_ref(), + id, + gix::refs::transaction::PreviousValue::Any, + format!("fetch {}", entry.tracking_ref()), + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + Ok(id) } @@ -515,3 +559,481 @@ fn resolve_vendor_paths( } Ok(paths) } + +/// Like [`resolve_vendor_paths`], but resolves against the current on-disk +/// index instead of a commit's tree. For use on an unborn `HEAD`, where +/// staged entries exist (e.g. right after `add`) but there is no commit yet +/// to read a tree from. Index entries are always files, so no tree-vs-blob +/// filtering is needed. +pub fn resolve_vendor_paths_uncommitted( + repo: &gix::Repository, + entry: &VendorEntry, +) -> Result, Error> { + let index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + let mut stack = repo.attributes_only( + &index, + gix::worktree::stack::state::attributes::Source::IdMapping, + )?; + let mut outcome = stack.selected_attribute_matches(["vendor"]); + + let mut paths = Vec::new(); + for e in index.entries() { + let path = e.path(&index).to_owned(); + let platform = stack.at_entry(path.as_bstr(), None)?; + outcome.reset(); + platform.matching_attributes(&mut outcome); + let is_ours = outcome.iter_selected().any(|m| { + matches!( + m.assignment.state, + gix::attrs::StateRef::Value(v) + if v.as_bstr() == entry.name.as_bytes().as_bstr() + ) + }); + if is_ours { + paths.push(path); + } + } + Ok(paths) +} + +// ── worktree impl ──────────────────────────────────────────────────────────── + +impl VendorWorktree for gix::Repository { + fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error> { + // IMPORTANT + // This is the trust boundary where upstream content (carried verbatim + // through `upstream_tree`, including symlink and gitlink modes, + // mirroring git-subtree/submodule) reaches the working copy. Like + // core git's `verify_path`/checkout, projection MUST refuse to write + // through a symlinked leading path and reject `..`/absolute + // components — use gix-worktree's checked checkout, never naive + // `std::fs` writes. See the `upstream_tree` adversarial review (#5). + // NOTE + // Path-traversal safety (e.g. `../` components, symlinked leading + // paths) is delegated to gix and is not covered by automated tests. + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + + let head_id = self.head_commit().ok().map(|c| c.id().detach()); + + let old_paths: std::collections::BTreeSet = head_id + .and_then(|id| resolve_vendor_paths(self, entry, id).ok()) + .into_iter() + .flatten() + .collect(); + + let mut vendor_index = self.index_from_tree(&tree)?; + + let new_paths: std::collections::BTreeSet = vendor_index + .entries() + .iter() + .map(|e| e.path(&vendor_index).to_owned()) + .collect(); + + let opts = self + .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) + .map_err(|e| Error::Gix(Box::new(e)))?; + let progress = gix::progress::Discard; + gix::worktree::state::checkout( + &mut vendor_index, + workdir, + self.objects.clone().into_arc().map_err(Error::Io)?, + &progress, + &progress, + &gix::interrupt::IS_INTERRUPTED, + gix::worktree::state::checkout::Options { + overwrite_existing: true, + ..opts + }, + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + + for removed in old_paths.difference(&new_paths) { + let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + remove_empty_ancestors(&abs, workdir); + } + + // The on-disk index must not be derived from an overlay of the vendor + // tree onto HEAD: that reads from HEAD, never the index, so any entry + // staged but not yet committed (an addition or modification) would be + // silently dropped. Instead, start from the actual current index — + // or an empty one if none exists yet — and surgically apply only this + // vendor's own path changes, leaving every other entry untouched. + let mut main_index = open_index_or_empty(self)?; + main_index.set_path(self.git_dir().join("index")); + + for removed in old_paths.difference(&new_paths) { + main_index.remove_entries(|_, p, _| p == removed.as_bstr()); + } + for e in vendor_index.entries() { + let path = e.path(&vendor_index).to_owned(); + main_index.remove_entries(|_, p, _| p == path.as_bstr()); + main_index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path.as_bstr()); + } + main_index.sort_entries(); + // `remove_entries`/`dangerously_push_entry` don't update the index's + // cached-tree extension, so a stale one would make a native `git + // commit` skip rehashing changed subtrees and record the wrong tree. + main_index.remove_tree(); + + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) + } + + fn checkout_vendor_conflicted( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + ) -> Result<(), Error> { + use gix::bstr::ByteSlice as _; + + self.checkout_vendor(entry, merge.result_tree)?; + + // Reopen the index we just wrote so we can splice in unmerged stages. + let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + main_index.set_path(self.git_dir().join("index")); + + for conflict in &merge.conflicts { + let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); + let path_bstr = path_bytes.as_bstr(); + + main_index.remove_entries(|_, p, _| p == path_bstr); + + for (stage_idx, stage_variant) in [ + (0usize, gix::index::entry::Stage::Base), + (1usize, gix::index::entry::Stage::Ours), + (2usize, gix::index::entry::Stage::Theirs), + ] { + if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { + let mode = gix::index::entry::Mode::from(tree_mode); + let flags = gix::index::entry::Flags::from_stage(stage_variant); + main_index.dangerously_push_entry( + gix::index::entry::Stat::default(), + oid, + flags, + mode, + path_bstr, + ); + } + } + } + + main_index.sort_entries(); + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) + } + + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + let existing: Vec = if gitattributes.exists() { + std::fs::read(&gitattributes)? + } else { + Vec::new() + }; + + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + for path in paths { + check_attr_pattern(path.as_bytes())?; + } + + let already_tracked: std::collections::HashSet> = existing + .lines() + .filter_map(|line| { + let (pattern, attr) = split_attr_line(line)?; + if attrs_contain(attr, attr_bytes) { + Some(unescape_attr_pattern(&pattern)) + } else { + None + } + }) + .collect(); + + let mut out = existing.clone(); + if !out.is_empty() && out.last() != Some(&b'\n') { + out.push(b'\n'); + } + for path in paths { + if !already_tracked.contains(path.as_bytes()) { + out.extend_from_slice(&escape_attr_pattern(path.as_bytes())); + out.push(b' '); + out.extend_from_slice(attr_bytes); + out.push(b'\n'); + } + } + + if out.is_empty() { + // Nothing to track and no pre-existing `.gitattributes`: don't + // materialize a phantom empty file on disk or in the index. + return Ok(self.write_object(gix::objs::BlobRef { data: b"" })?.detach()); + } + + if out != existing { + std::fs::write(&gitattributes, &out)?; + } + + stage_gitattributes(self, &out) + } + + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error> { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + if !gitattributes.exists() { + return Ok(None); + } + + let existing: Vec = std::fs::read(&gitattributes)?; + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + + let mut filtered: Vec = Vec::with_capacity(existing.len()); + for line in existing.lines() { + let matched = match split_attr_line(line) { + Some((pattern, attr)) => { + attrs_contain(attr, attr_bytes) + && remove.contains(unescape_attr_pattern(&pattern).as_slice()) + } + None => false, + }; + if !matched { + filtered.extend_from_slice(line); + filtered.push(b'\n'); + continue; + } + // The line's pattern is one we're untracking. Drop only the + // `vendor=` attribute, preserving any other attributes a + // hand edit may have added on the same line; drop the whole line + // only when nothing else remains. + let (pattern, attr) = split_attr_line(line).expect("matched line re-splits"); + let remaining: Vec<&[u8]> = attr + .split(|b: &u8| b.is_ascii_whitespace()) + .filter(|t| !t.is_empty() && *t != attr_bytes) + .collect(); + if !remaining.is_empty() { + filtered.extend_from_slice(pattern.as_ref()); + for token in remaining { + filtered.push(b' '); + filtered.extend_from_slice(token); + } + filtered.push(b'\n'); + } + } + + if filtered != existing { + std::fs::write(&gitattributes, &filtered)?; + } + + Ok(Some(stage_gitattributes(self, &filtered)?)) + } + + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error> { + let git_dir = self.git_dir(); + if entry.mode == VendorMode::Squash { + std::fs::write(git_dir.join("SQUASH_MSG"), message.as_bytes())?; + } else { + std::fs::write( + git_dir.join("MERGE_HEAD"), + format!("{}\n", merge.upstream_commit), + )?; + std::fs::write(git_dir.join("MERGE_MSG"), message.as_bytes())?; + } + Ok(()) + } +} + +/// Open the repository index, treating only a missing index file as an empty +/// index. A corrupt or otherwise unreadable index is a real error and is +/// propagated — silently degrading it to an empty index would drop every +/// staged entry, the exact hazard `checkout_vendor`'s surgical index update +/// exists to avoid. +fn open_index_or_empty(repo: &gix::Repository) -> Result { + match repo.open_index() { + Ok(idx) => Ok(idx), + Err(gix::worktree::open_index::Error::IndexFile( + gix::index::file::init::Error::Io(io), + )) if io.kind() == std::io::ErrorKind::NotFound => { + Ok(repo.index_from_tree(&gix::ObjectId::empty_tree(repo.object_hash()))?) + } + Err(e) => Err(Error::Gix(Box::new(e))), + } +} + +/// Remove `path`'s parent directory and each ancestor above it, as long as +/// they're empty and still inside `workdir`. Stops at the first non-empty or +/// out-of-bounds directory. +fn remove_empty_ancestors(path: &std::path::Path, workdir: &std::path::Path) { + let mut dir = path.parent(); + while let Some(d) = dir { + if d == workdir || !d.starts_with(workdir) { + break; + } + if std::fs::remove_dir(d).is_err() { + break; + } + dir = d.parent(); + } +} + +/// Check that every path in `paths` can be written as a plain (unquoted) +/// `.gitattributes` pattern. Callers that will later checkout files and +/// mutate the working tree/index should validate paths with this *before* +/// doing so, so an invalid path aborts cleanly instead of leaving a +/// half-applied checkout behind — see [`check_attr_pattern`] for what's +/// rejected. +pub fn validate_trackable_paths(paths: &[&BStr]) -> Result<(), Error> { + for p in paths { + check_attr_pattern(p.as_bytes())?; + } + Ok(()) +} + +/// Return `Err` if `path` contains characters that would require C-style +/// quoting to write as an unquoted `.gitattributes` pattern: whitespace +/// (space, tab), a literal `"` or `\`, or control characters. A leading `#` +/// or `!` and glob metacharacters (`*`, `?`, `[`) are *not* rejected here — +/// [`escape_attr_pattern`] backslash-escapes those so the path still matches +/// only itself. Quoted-pattern writing is not yet implemented, so a path with +/// these characters (e.g. one containing a space) cannot currently be +/// vendored; such paths are uncommon but do occur. +fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { + let needs_quoting = |b: u8| b.is_ascii_control() || matches!(b, b' ' | b'"' | b'\\'); + if path.iter().copied().any(needs_quoting) { + return Err(Error::InvalidPath( + String::from_utf8_lossy(path).into_owned(), + )); + } + Ok(()) +} + +/// Escape glob metacharacters (`*`, `?`, `[`) and a pattern-initial `!` or `#` +/// with a backslash, so `path` matches only itself as a `.gitattributes` +/// pattern. `check_attr_pattern` already rejects a raw `\` in the input, so +/// every backslash in the result is unambiguously one we inserted here. +fn escape_attr_pattern(path: &[u8]) -> Vec { + let mut out = Vec::with_capacity(path.len()); + for (i, &b) in path.iter().enumerate() { + if matches!(b, b'*' | b'?' | b'[') || (i == 0 && matches!(b, b'!' | b'#')) { + out.push(b'\\'); + } + out.push(b); + } + out +} + +/// Inverse of [`escape_attr_pattern`]. +fn unescape_attr_pattern(pattern: &[u8]) -> Vec { + let mut out = Vec::with_capacity(pattern.len()); + let mut i = 0; + while i < pattern.len() { + if pattern[i] == b'\\' + && i + 1 < pattern.len() + && (matches!(pattern[i + 1], b'*' | b'?' | b'[') + || (i == 0 && matches!(pattern[i + 1], b'!' | b'#'))) + { + i += 1; + } + out.push(pattern[i]); + i += 1; + } + out +} + +/// Whether `attrs` — the attribute portion of a `.gitattributes` line — lists +/// `token` as one of its whitespace-separated attributes. Compares tokens +/// rather than the whole tail so a hand-edited multi-attribute line like +/// `p vendor=mylib text` is still recognized as carrying `vendor=mylib`. +fn attrs_contain(attrs: &[u8], token: &[u8]) -> bool { + attrs.split(|b: &u8| b.is_ascii_whitespace()).any(|t| t == token) +} + +/// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. +/// +/// Returns `None` for blank lines, comment lines, or lines with no attribute +/// separator. Handles both plain and C-style-quoted patterns using +/// [`gix_quote::ansi_c::undo`]. +fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { + if line.is_empty() || line[0] == b'#' { + return None; + } + if line.starts_with(b"\"") { + let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; + let rest = line.get(consumed..)?; + if rest.first().is_some_and(|&b| b == b' ' || b == b'\t') { + let owned: Vec = pattern.as_ref().to_vec(); + Some((std::borrow::Cow::Owned(owned), rest[1..].trim())) + } else { + None + } + } else { + let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; + if pos == 0 { + return None; + } + Some(( + std::borrow::Cow::Borrowed(&line[..pos]), + line[pos + 1..].trim(), + )) + } +} + +#[cfg(test)] +#[path = "attr_tests.rs"] +mod tests; + +/// Write `content` as a blob into the object database, upsert the index entry +/// at `path` (a repo-root-relative bytestring) to point at it, and return the +/// blob OID. Used to stage the tracked control files (`.gitattributes`, +/// `.gitvendors`) a vendor operation rewrites. +pub fn stage_file( + repo: &gix::Repository, + path: &BStr, + content: &[u8], +) -> Result { + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content })? + .detach(); + + let mut index = open_index_or_empty(repo)?; + index.set_path(repo.git_dir().join("index")); + index.remove_entries(|_, p, _| p == path); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + path, + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(blob_oid) +} + +fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result { + stage_file(repo, b".gitattributes".as_bstr(), content) +} diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 209b9b6..e72eb51 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -1,2 +1,24 @@ -// TODO -fn main() {} +#![allow(missing_docs)] + +mod cli; +mod exe; + +use anyhow::Result; +use clap::Parser as _; + +fn main() { + if let Err(e) = run() { + // A conflict has already staged the working tree and printed actionable + // guidance to stderr, mirroring `git merge`: exit non-zero without the + // `error:` prefix. Anything else is a genuine failure worth rendering. + if !matches!(e.downcast_ref::(), Some(exe::Error::Conflict)) { + eprintln!("error: {e}"); + } + std::process::exit(1); + } +} + +fn run() -> Result<()> { + exe::Executor::discover()?.run(cli::Cli::parse(), &mut exe::Io::stdio())?; + Ok(()) +} diff --git a/crates/git-vendor/src/vendor.rs b/crates/git-vendor/src/vendor.rs index 9fb9a03..6e863f5 100644 --- a/crates/git-vendor/src/vendor.rs +++ b/crates/git-vendor/src/vendor.rs @@ -600,7 +600,8 @@ pub trait VendorWorktree { /// Update the index and working tree for the vendor's paths to match `tree`. /// /// Only paths owned by this vendor are written; unrelated files are left - /// untouched. + /// untouched. Empty ancestor directories left behind by removed paths are + /// pruned. fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error>; /// Project a conflicted merge onto the working copy for manual resolution. @@ -624,14 +625,32 @@ pub trait VendorWorktree { ) -> Result<(), Error>; /// Add the given paths to the vendor's local content filter by writing - /// `vendor=` entries into the working-copy `.gitattributes`. + /// `vendor=` entries into the working-copy `.gitattributes` and + /// staging the updated file into the index. /// /// This authors local-side membership (read back by /// [`VendorRepository::vendor_paths`](crate::VendorRepository::vendor_paths)); /// it is independent of the upstream pattern filter. - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result; /// Remove the given paths from the vendor's content filter, deleting their - /// `vendor=` entries from the working-copy `.gitattributes`. - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + /// `vendor=` entries from the working-copy `.gitattributes` and + /// staging the updated file into the index. + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error>; + + /// Stage the merge result for a subsequent `git commit`. + /// + /// Writes `MERGE_HEAD` + `MERGE_MSG` (merge mode) or `SQUASH_MSG` (squash + /// mode) so that the user's own `git commit` produces the right commit + /// shape and has the default message pre-filled. + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error>; } diff --git a/crates/git-vendor/tests/checkout_vendor/table.rs b/crates/git-vendor/tests/checkout_vendor/table.rs index 29daddf..d3f8f7c 100644 --- a/crates/git-vendor/tests/checkout_vendor/table.rs +++ b/crates/git-vendor/tests/checkout_vendor/table.rs @@ -212,11 +212,11 @@ fn stale_live_symlink_is_removed() { assert!(workdir.join("README").exists()); } -/// After all vendor files are removed the parent directory is left behind — -/// `remove_file` does not prune empty directories. This test pins that -/// current behavior. +/// After all vendor files are removed the now-empty parent directory is +/// pruned, matching `remove`'s behavior (and git's own checkout, which does +/// not leave emptied directories behind). #[test] -fn stale_empty_vendor_directory_is_not_pruned() { +fn stale_empty_vendor_directory_is_pruned() { let b = build(); let workdir = b.repo.workdir().unwrap().to_owned(); // New tree carries no vendor files; both old vendor files are removed. @@ -228,8 +228,8 @@ fn stale_empty_vendor_directory_is_not_pruned() { assert!(!workdir.join("vendor/old.txt").exists()); assert!(!workdir.join("vendor/keep.txt").exists()); assert!( - workdir.join("vendor").is_dir(), - "emptied vendor/ dir is left behind by the current impl", + !workdir.join("vendor").exists(), + "emptied vendor/ dir must be pruned after its last file is removed", ); } @@ -273,3 +273,48 @@ fn index_entries_are_sorted_after_checkout() { sorted.sort(); assert_eq!(paths, sorted, "index entries must be sorted after checkout"); } + +/// A file staged but not yet committed must survive `checkout_vendor`. +/// Regression: the on-disk index was rebuilt from `full_tree`, which overlays +/// the vendor tree onto *HEAD's* committed tree — never the index — so any +/// staged-but-uncommitted addition was silently dropped from the index. +#[test] +fn staged_uncommitted_addition_is_preserved() { + let b = build(); + let workdir = b.repo.workdir().unwrap().to_owned(); + write(&workdir, "newfile.txt", b"staged\n"); + git(&["add", "newfile.txt"], &workdir); + + let tree = build_tree(&b.repo, &[("vendor/keep.txt", b"v2\n")]); + b.repo.checkout_vendor(&entry(), tree).expect("checkout"); + + let paths = index_paths(&b.repo); + assert!( + paths.contains(&"newfile.txt".to_owned()), + "staged addition must survive checkout: {paths:?}", + ); +} + +/// The same regression on an unborn `HEAD`: staging a file before the first +/// commit, then running `checkout_vendor`, must not wipe out the entire +/// pre-existing index (there is no committed tree to fall back to at all in +/// this case, so the old behavior dropped every staged entry). +#[test] +fn staged_uncommitted_addition_survives_unborn_head_checkout() { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"local readme\n"); + git(&["add", "README"], dir.path()); + // No commit — HEAD is unborn. + let repo = gix::open(dir.path()).unwrap(); + + let tree = build_tree(&repo, &[("vendor/new.txt", b"new\n")]); + repo.checkout_vendor(&entry(), tree) + .expect("checkout into unborn HEAD"); + + let paths = index_paths(&repo); + assert!( + paths.contains(&"README".to_owned()), + "pre-existing staged entry must survive checkout: {paths:?}", + ); +} diff --git a/crates/git-vendor/tests/cli.rs b/crates/git-vendor/tests/cli.rs new file mode 100644 index 0000000..34d86ec --- /dev/null +++ b/crates/git-vendor/tests/cli.rs @@ -0,0 +1,6 @@ +mod support; +mod cli { + mod add; + mod remove; + mod update; +} diff --git a/crates/git-vendor/tests/cli/add.rs b/crates/git-vendor/tests/cli/add.rs new file mode 100644 index 0000000..3de90f7 --- /dev/null +++ b/crates/git-vendor/tests/cli/add.rs @@ -0,0 +1,51 @@ +//! End-to-end tests for `git-vendor add`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// `add` must validate that every upstream path can be written as an +/// unquoted `.gitattributes` pattern *before* checking out any files, so an +/// unquotable path (e.g. containing a space) aborts cleanly instead of +/// leaving a half-applied checkout: vendored files present on disk and +/// staged in the index, but no `.gitvendors` entry and no `.gitattributes` +/// tracking. Regression: the check ran inside `track_vendor`, which is +/// called only *after* `checkout_vendor` has already mutated the working +/// tree and index. +#[test] +fn add_rejects_unquotable_path_without_partial_mutation() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + init(upstream.path()); + write(upstream.path(), "read me.txt", b"hello\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "c"], upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + let out = vendor(&["add", url, "mylib"], local.path()); + assert!( + !out.status.success(), + "add must fail when an upstream path cannot be written as an unquoted \ + .gitattributes pattern", + ); + + assert!( + !local.path().join("vendor").exists(), + "no partial checkout should be left behind on disk", + ); + + let status = String::from_utf8(git_capture(&["status", "--porcelain"], local.path())).unwrap(); + assert!( + status.is_empty(), + "working tree/index must be untouched after the rejected add, but status was:\n{status}", + ); + + assert!( + !local.path().join(".gitvendors").exists(), + ".gitvendors must not be created by a rejected add", + ); +} diff --git a/crates/git-vendor/tests/cli/remove.rs b/crates/git-vendor/tests/cli/remove.rs new file mode 100644 index 0000000..5042b5b --- /dev/null +++ b/crates/git-vendor/tests/cli/remove.rs @@ -0,0 +1,163 @@ +//! End-to-end tests for `git-vendor remove`. + +use crate::support::{git, git_capture, init, make_upstream, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// `remove` must stage the rewritten `.gitvendors` so the user's next commit +/// records the deletion. Regression: `cmd_remove` wrote `.gitvendors` to the +/// working tree but never staged it, leaving the index pointing at the old +/// blob that still listed the removed vendor. +#[test] +fn remove_stages_updated_gitvendors() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + // `add` leaves the merge staged with MERGE_HEAD set; seal it. + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + let committed = + String::from_utf8(git_capture(&["show", "HEAD:.gitvendors"], local.path())).unwrap(); + assert!( + committed.contains("mylib"), + "precondition: committed .gitvendors must list the vendor:\n{committed}", + ); + + vendor_ok(&["remove", "mylib"], local.path()); + + let staged = String::from_utf8(git_capture(&["show", ":.gitvendors"], local.path())).unwrap(); + assert!( + !staged.contains("mylib"), + "staged .gitvendors must not reference the removed vendor, but was:\n{staged}", + ); +} + +/// `remove --keep-files` must still untrack the vendor's `.gitattributes` +/// entries, even though it leaves the files themselves on disk. Regression: +/// `untrack_vendor` was called only in the `!keep_files` branch, so +/// `--keep-files` left stale `vendor=` lines behind. +#[test] +fn remove_keep_files_still_untracks_gitattributes() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + vendor_ok(&["remove", "--keep-files", "mylib"], local.path()); + + assert!( + local.path().join("vendor/mylib/hello.txt").exists(), + "--keep-files must leave the vendored files on disk", + ); + + let staged = + String::from_utf8(git_capture(&["show", ":.gitattributes"], local.path())).unwrap(); + assert!( + !staged.contains("vendor=mylib"), + "staged .gitattributes must not reference the removed vendor, but was:\n{staged}", + ); +} + +/// `remove` on a born HEAD but *before committing the add* must still remove +/// the vendor's files. Regression: `remove` resolved paths only from HEAD's +/// tree, which — for a vendor added but not yet committed — carries no +/// `vendor=` tracking, so it reported success while leaving the files on +/// disk, in the index, and in `.gitattributes`. +#[test] +fn remove_after_uncommitted_add_removes_vendor() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + // Deliberately do NOT commit the add — the vendor lives only in the index. + vendor_ok(&["remove", "mylib"], local.path()); + + assert!( + !local.path().join("vendor/mylib/hello.txt").exists(), + "remove must delete files from an uncommitted add", + ); + let tracked = String::from_utf8(git_capture(&["ls-files"], local.path())).unwrap(); + assert!( + !tracked.lines().any(|l| l.starts_with("vendor/mylib/")), + "no vendor/mylib/* path should remain in the index:\n{tracked}", + ); + let staged = + String::from_utf8(git_capture(&["show", ":.gitattributes"], local.path())).unwrap(); + assert!( + !staged.contains("vendor=mylib"), + "staged .gitattributes must not reference the removed vendor:\n{staged}", + ); +} + +/// `remove` on an unborn HEAD (before the first commit) must actually remove +/// the vendor, not just its `.gitvendors` entry. Regression: file deletion, +/// `untrack_vendor`, and index cleanup were all gated on `repo.head_commit()` +/// succeeding, so on an unborn HEAD `remove` reported success while leaving +/// vendored files on disk, in the index, and in `.gitattributes`. +#[test] +fn remove_before_first_commit_removes_vendor() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + init(local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + + vendor_ok(&["remove", "mylib"], local.path()); + + assert!( + !local.path().join("vendor/mylib/hello.txt").exists(), + "remove must delete vendored files even on an unborn HEAD", + ); + + let tracked = String::from_utf8(git_capture(&["ls-files"], local.path())).unwrap(); + assert!( + !tracked.lines().any(|l| l.starts_with("vendor/mylib/")), + "no vendor/mylib/* path should remain in the index, but ls-files was:\n{tracked}", + ); + + if local.path().join(".gitattributes").exists() { + let contents = std::fs::read_to_string(local.path().join(".gitattributes")).unwrap(); + assert!( + !contents.contains("vendor=mylib"), + ".gitattributes must not reference the removed vendor, but was:\n{contents}", + ); + } +} diff --git a/crates/git-vendor/tests/cli/update.rs b/crates/git-vendor/tests/cli/update.rs new file mode 100644 index 0000000..df619b1 --- /dev/null +++ b/crates/git-vendor/tests/cli/update.rs @@ -0,0 +1,76 @@ +//! End-to-end tests for `git-vendor update`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// A conflicting `update` must still record `.gitattributes` tracking for the +/// vendor paths it introduces, so that once the user resolves the conflict and +/// commits, later `status`/`update`/`remove` can still find the vendor's files. +/// +/// Regression: the conflict branch of `cmd_update` checked out the conflicted +/// tree and prepared the merge but skipped `reconcile_tracked_paths`, so a +/// newly vendored file landing alongside a conflict was never written to +/// `.gitattributes`. +#[test] +fn conflicting_update_tracks_new_paths() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + // Upstream v1: a single file that the local side will diverge from. + init(upstream.path()); + write(upstream.path(), "hello.txt", b"v1\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v1"], upstream.path()); + + // Local: vendor it (default prefix vendor/mylib/) and seal the add. + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + // Local patch: diverge the vendored file so the next update conflicts. + write(local.path(), "vendor/mylib/hello.txt", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "local patch"], local.path()); + + // Upstream v2: change the same file (forcing a conflict) and add a brand + // new file (the path that must still get tracked). + write(upstream.path(), "hello.txt", b"v2\n"); + write(upstream.path(), "extra.txt", b"new\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v2"], upstream.path()); + + // Update must conflict (exit non-zero) and leave the merge in progress. + let out = vendor(&["update", "mylib"], local.path()); + assert!( + !out.status.success(), + "update should report the conflict via non-zero exit:\n{}", + String::from_utf8_lossy(&out.stderr), + ); + + // Resolve the conflict and complete the merge the user is told to make. + write(local.path(), "vendor/mylib/hello.txt", b"resolved\n"); + git(&["add", "vendor/mylib/hello.txt"], local.path()); + git(&["commit", "--no-edit"], local.path()); + + let attrs = + String::from_utf8(git_capture(&["show", "HEAD:.gitattributes"], local.path())).unwrap(); + assert!( + attrs.contains("vendor/mylib/extra.txt"), + "the newly vendored path must be tracked in .gitattributes, but was:\n{attrs}", + ); +} diff --git a/crates/git-vendor/tests/fetch_vendor/table.rs b/crates/git-vendor/tests/fetch_vendor/table.rs index 14d6f3f..623294e 100644 --- a/crates/git-vendor/tests/fetch_vendor/table.rs +++ b/crates/git-vendor/tests/fetch_vendor/table.rs @@ -111,8 +111,9 @@ fn fetch_force_updates_on_upstream_rewrite() { ); } -/// Fetching an annotated tag stores the tag object at `refs/vendor/` but -/// returns the tag's ultimate (peeled) target, per the documented contract. +/// Fetching an annotated tag returns the tag's ultimate (peeled) target and +/// stores that same peeled commit at `refs/vendor/` — never the tag +/// object itself, per the documented contract. #[test] fn fetch_peels_annotated_tag() { let upstream = tempfile::tempdir().unwrap(); @@ -132,8 +133,35 @@ fn fetch_peels_annotated_tag() { let reference = repo.find_reference(&entry.vendor_ref()).expect("find ref"); assert_eq!( reference.id().detach(), - tag_obj, - "stored ref must point at the tag object itself", + commit, + "stored ref must point directly at the peeled commit, not the tag object", + ); +} + +/// Fetching by the annotated tag's own object SHA (`Source::ObjectId`, as +/// opposed to fetching it by name) must still return the peeled commit, not +/// the raw tag object. +#[test] +fn fetch_by_oid_peels_annotated_tag() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + let commit = make_upstream(upstream.path()); + git(&["tag", "-a", "v1", "-m", "release one"], upstream.path()); + let tag_obj = rev_parse(upstream.path(), "v1"); + assert_ne!(tag_obj, commit, "annotated tag must be its own object"); + + let repo = make_local(local.path()); + let entry = make_entry( + upstream.path().to_str().unwrap(), + Some(&tag_obj.to_string()), + vec![], + ); + + let got = repo.fetch_vendor(&entry).expect("fetch_vendor"); + assert_eq!( + got, commit, + "returned id must be the peeled commit, not the tag object fetched by SHA" ); } @@ -254,11 +282,7 @@ fn fetch_errors_when_ref_missing() { /// and is unaffected; this test pins the non-bare case so the regression is /// hard to reintroduce once fixed. /// -/// Marked `#[should_panic]` so CI passes while the upstream gix bug stands; -/// when the bug is fixed this flips to a regular failure and the attribute -/// is removed. #[test] -#[should_panic] fn fetch_returns_upstream_tip_into_non_bare_local() { let upstream = tempfile::tempdir().unwrap(); let local = tempfile::tempdir().unwrap(); @@ -323,4 +347,17 @@ fn fetch_returns_upstream_tip_into_non_bare_local() { "fetched tree lacks the upstream-only `up/marker.txt`: upstream \ objects were not actually brought into the local odb", ); + + // 3. `vendor_tip` (and by extension `vendor_status`) must read the same + // corrected value back from `refs/vendor/`, not the + // gix#2613-corrupted symref pointing at the local branch. + let tip = repo + .vendor_tip(&entry) + .expect("vendor_tip") + .expect("some tip"); + assert_eq!( + tip, upstream_head, + "vendor_tip must not resolve the gix#2613-corrupted symref to the \ + local branch", + ); } diff --git a/crates/git-vendor/tests/prepare_merge.rs b/crates/git-vendor/tests/prepare_merge.rs new file mode 100644 index 0000000..b83dc26 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge.rs @@ -0,0 +1,4 @@ +mod support; +mod prepare_merge { + mod table; +} diff --git a/crates/git-vendor/tests/prepare_merge/table.rs b/crates/git-vendor/tests/prepare_merge/table.rs new file mode 100644 index 0000000..864c7c8 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge/table.rs @@ -0,0 +1,134 @@ +//! Tests for `prepare_merge`. +//! +//! `prepare_merge` writes the git-dir files that put the working copy into +//! merge state so the user can run `git commit` to seal the vendor merge. +//! In squash mode it writes `SQUASH_MSG`; in merge mode it writes `MERGE_HEAD` +//! and `MERGE_MSG`. + +use git_vendor::{ + PatternMapping, VendorEntry, VendorMerge, VendorMode, VendorName, VendorWorktree as _, +}; + +use crate::support::{git, init, write}; + +fn null_oid() -> gix::ObjectId { + gix::ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap() +} + +fn make_merge() -> VendorMerge { + VendorMerge { + upstream_commit: null_oid(), + ancestor_tree: None, + result_tree: null_oid(), + conflicts: vec![], + } +} + +fn entry(mode: VendorMode) -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: vec![PatternMapping { + glob: "up/**".to_owned(), + destination: Some("vendor/".to_owned()), + }], + mode, + } +} + +struct Built { + _dir: tempfile::TempDir, + repo: gix::Repository, +} + +fn build_repo() -> Built { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"hello\n"); + git(&["add", "-A"], dir.path()); + git(&["commit", "-m", "initial"], dir.path()); + let repo = gix::open(dir.path()).expect("gix open"); + Built { _dir: dir, repo } +} + +/// Merge mode writes `MERGE_HEAD` containing the upstream commit OID followed +/// by a newline, and `MERGE_MSG` containing the message. +#[test] +fn merge_mode_writes_merge_head_and_merge_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: update mylib\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let merge_head = std::fs::read_to_string(git_dir.join("MERGE_HEAD")).unwrap(); + assert_eq!( + merge_head, + format!("{}\n", null_oid()), + "MERGE_HEAD must be \\n", + ); + + let merge_msg = std::fs::read_to_string(git_dir.join("MERGE_MSG")).unwrap(); + assert_eq!( + merge_msg, message, + "MERGE_MSG must equal the supplied message" + ); + + assert!( + !git_dir.join("SQUASH_MSG").exists(), + "SQUASH_MSG must not be written in merge mode", + ); +} + +/// Squash mode writes `SQUASH_MSG` containing the message and does not write +/// `MERGE_HEAD` or `MERGE_MSG`. +#[test] +fn squash_mode_writes_squash_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: add mylib (squash)\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Squash), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let squash_msg = std::fs::read_to_string(git_dir.join("SQUASH_MSG")).unwrap(); + assert_eq!( + squash_msg, message, + "SQUASH_MSG must equal the supplied message", + ); + + assert!( + !git_dir.join("MERGE_HEAD").exists(), + "MERGE_HEAD must not be written in squash mode", + ); + assert!( + !git_dir.join("MERGE_MSG").exists(), + "MERGE_MSG must not be written in squash mode", + ); +} + +/// Calling `prepare_merge` a second time overwrites the previous state files. +#[test] +fn second_call_overwrites_previous_files() { + let b = build_repo(); + let merge = make_merge(); + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "first\n") + .expect("first call"); + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "second\n") + .expect("second call"); + + let merge_msg = std::fs::read_to_string(b.repo.git_dir().join("MERGE_MSG")).unwrap(); + assert_eq!(merge_msg, "second\n"); +} diff --git a/crates/git-vendor/tests/support/mod.rs b/crates/git-vendor/tests/support/mod.rs index f052956..c52025f 100644 --- a/crates/git-vendor/tests/support/mod.rs +++ b/crates/git-vendor/tests/support/mod.rs @@ -45,6 +45,19 @@ pub fn git_capture(args: &[&str], dir: &Path) -> Vec { output.stdout } +/// Run the compiled `git-vendor` binary in `dir` with isolated config and +/// return its captured [`Output`](std::process::Output) without asserting +/// success, so tests can inspect both exit status and streams. +pub fn vendor(args: &[&str], dir: &Path) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_git-vendor")) + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git-vendor") +} + /// `git rev-parse ` in `dir`, return the resolved OID. pub fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { let out = git_capture(&["rev-parse", rev], dir); diff --git a/crates/git-vendor/tests/track_vendor/table.rs b/crates/git-vendor/tests/track_vendor/table.rs index ce648dc..967a433 100644 --- a/crates/git-vendor/tests/track_vendor/table.rs +++ b/crates/git-vendor/tests/track_vendor/table.rs @@ -133,6 +133,20 @@ fn preserves_unrelated_lines() { ); } +/// A path containing a space is rejected with `Error::InvalidPath`. +#[test] +fn path_with_space_returns_invalid_path_error() { + let b = build_without_attributes(); + let err = b + .repo + .track_vendor(&entry(), &[b"vendor/a b.txt".as_bstr()]) + .unwrap_err(); + assert!( + matches!(err, git_vendor::Error::InvalidPath(_)), + "expected InvalidPath, got {err:?}", + ); +} + /// A bare repo returns `Error::NoWorkdir`. #[test] fn bare_repo_returns_no_workdir_error() { @@ -144,3 +158,63 @@ fn bare_repo_returns_no_workdir_error() { .unwrap_err(); assert!(matches!(err, git_vendor::Error::NoWorkdir), "{err:?}"); } + +/// A path containing a glob metacharacter (`*`) is written with it escaped, +/// so the pattern matches only that literal path rather than becoming a live +/// glob. Regression: `track_vendor` wrote the raw path bytes verbatim, so a +/// file named `a*.txt` produced the pattern `a*.txt`, matching unrelated +/// siblings too. +#[test] +fn glob_metacharacter_is_escaped_in_written_pattern() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("track_vendor"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + assert_eq!(content, "vendor/a\\*.txt vendor=mylib\n"); +} + +/// A path whose first component starts with `#` is accepted (not rejected as +/// unquotable) and written with the leading `#` escaped, so the line is not +/// mistaken for a comment. Round-trips through dedup without duplicating. +#[test] +fn leading_hash_path_is_escaped_not_rejected() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"#notes.txt".as_bstr()]) + .expect("track_vendor"); + b.repo + .track_vendor(&entry(), &[b"#notes.txt".as_bstr()]) + .expect("second call"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + assert_eq!(content, "\\#notes.txt vendor=mylib\n"); +} + +/// Tracking the same glob-metacharacter path twice does not duplicate the +/// line: dedup must compare against the *unescaped* path, not the raw +/// written pattern. +#[test] +fn glob_metacharacter_path_dedup_survives_escaping() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("first call"); + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("second call"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + let count = content + .lines() + .filter(|l| *l == "vendor/a\\*.txt vendor=mylib") + .count(); + assert_eq!(count, 1, "line must appear exactly once: {content:?}"); +} diff --git a/crates/git-vendor/tests/untrack_vendor/table.rs b/crates/git-vendor/tests/untrack_vendor/table.rs index ea0503a..7fe0089 100644 --- a/crates/git-vendor/tests/untrack_vendor/table.rs +++ b/crates/git-vendor/tests/untrack_vendor/table.rs @@ -121,6 +121,25 @@ fn no_gitattributes_is_noop() { assert!(!attrs_path.exists(), ".gitattributes must not be created"); } +/// A hand-edited line carrying extra attributes alongside `vendor=` +/// is recognized (token-wise, not whole-tail) and has only the `vendor=` +/// attribute stripped — the other attributes and the line survive. +#[test] +fn strips_only_vendor_token_from_multi_attribute_line() { + let b = build_with_attributes(b"vendor/a.txt vendor=mylib text=auto\n"); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .untrack_vendor(&entry(), &[b"vendor/a.txt".as_bstr()]) + .expect("untrack_vendor"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + assert_eq!( + content, "vendor/a.txt text=auto\n", + "only the vendor= token must be dropped, keeping other attributes", + ); +} + /// A bare repo returns `Error::NoWorkdir`. #[test] fn bare_repo_returns_no_workdir_error() { diff --git a/crates/git-vendor/tests/vendor_paths/table.rs b/crates/git-vendor/tests/vendor_paths/table.rs index 5b120e0..1185c13 100644 --- a/crates/git-vendor/tests/vendor_paths/table.rs +++ b/crates/git-vendor/tests/vendor_paths/table.rs @@ -10,8 +10,10 @@ use std::collections::BTreeSet; use std::path::Path; -use git_vendor::{PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _}; -use gix::bstr::BString; +use git_vendor::{ + PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _, VendorWorktree as _, +}; +use gix::bstr::{BString, ByteSlice as _}; use rstest::rstest; use crate::support::{commit, git, init, write}; @@ -747,3 +749,35 @@ fn empty_tree_selects_nothing() { .is_empty() ); } + +// ── Glob-metacharacter paths ──────────────────────────────────────────────── + +/// A tracked path containing a glob metacharacter (`*`) must resolve to only +/// itself, not to unrelated siblings the raw (unescaped) glob would also +/// match. Regression: `track_vendor` wrote the raw path bytes verbatim as the +/// `.gitattributes` pattern, so a file literally named `a*` produced the live +/// glob `vendor/mylib/a*`, matching any sibling starting with `a`. +#[test] +fn glob_metacharacter_path_does_not_leak_to_siblings() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + init(p); + write(p, "vendor/mylib/a*", b"literal"); + write(p, "vendor/mylib/aXtxt", b"sibling"); + git(&["add", "-A"], p); + git(&["commit", "-m", "initial"], p); + let repo = gix::open(p).expect("gix open"); + + repo.track_vendor(&entry("mylib", vec![]), &[b"vendor/mylib/a*".as_bstr()]) + .expect("track_vendor"); + git(&["add", "-A"], p); + git(&["commit", "-m", "track"], p); + + let ours = repo.head_commit().expect("head commit").id().detach(); + assert_eq!( + repo.vendor_paths(&entry("mylib", vec![]), ours) + .expect("vendor_paths"), + &[BString::from("vendor/mylib/a*")], + "only the literal path must be selected, not the unrelated sibling", + ); +}