commit 947e71d1fde00cabb591667a462b463e1ea8ad2b Author: Azalea Date: Sun May 3 17:24:24 2026 +0000 [+] Create repo by codex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6385693 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1980 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "git-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "directories", + "reqwest", + "serde", + "serde_json", + "tempfile", + "toml", + "url", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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 = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "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", + "webpki-roots", +] + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +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 = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-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", + "ring", + "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 = [ + "web-time", + "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]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "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.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +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 = "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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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 = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..134c5c2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "git-sync" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +directories = "5.0" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tempfile = "3.13" +toml = "0.8" +url = "2.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..45363cc --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# git-sync + +`git-sync` mirrors repositories between Git hosting providers when you run it. It does not install daemons, webhooks, timers, or cron jobs. + +Supported providers: + +- GitHub +- GitLab +- Gitea + +The program uses provider APIs to list and create repositories, then uses the local `git` CLI to fetch and push branches and tags. + +## Install + +```sh +cargo build --release +``` + +The binary will be at `target/release/git-sync`. + +## Configure + +Create the config file: + +```sh +git-sync config init +``` + +Add sites. Prefer `--token-env` so PATs do not live in shell history or the config file. + +```sh +git-sync config site add \ + --name github \ + --provider github \ + --base-url https://github.com \ + --token-env GITHUB_TOKEN + +git-sync config site add \ + --name gitea \ + --provider gitea \ + --base-url https://gitea.example.com \ + --token-env GITEA_TOKEN +``` + +For self-hosted providers, `--base-url` is the web root. API URLs default to: + +- GitHub.com: `https://api.github.com` +- GitHub Enterprise: `/api/v3` +- GitLab: `/api/v4` +- Gitea: `/api/v1` + +Override with `--api-url` if your instance is different. + +Add one or more mirror groups. Endpoints use `SITE:KIND:NAMESPACE`, where kind is `user`, `org`, or `group` depending on the provider. + +```sh +git-sync config mirror add \ + --name personal \ + --endpoint github:user:hykilpikonna \ + --endpoint gitea:user:azalea + +git-sync config mirror add \ + --name mewolab \ + --endpoint github:org:MewoLab \ + --endpoint gitea:org:MewoLab +``` + +You can inspect the generated config with: + +```sh +git-sync config show +``` + +## Sync + +Run all configured mirror groups: + +```sh +git-sync sync +``` + +Run one group: + +```sh +git-sync sync --group personal +``` + +Preview commands without writing to Git remotes: + +```sh +git-sync sync --dry-run +``` + +Use cron or another scheduler for automatic execution: + +```cron +*/15 * * * * GITHUB_TOKEN=... GITEA_TOKEN=... /path/to/git-sync sync +``` + +## Sync Semantics + +Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints. + +For every repository name found in any endpoint, `git-sync` will: + +1. Create missing repositories on the other endpoints when `create_missing = true`. +2. Fetch all branches and tags from each existing endpoint into a local bare mirror cache. +3. Compare branch tips across endpoints. +4. Push the winning branch tip to every endpoint. + +Branch conflict handling is intentionally conservative: + +- If all endpoints agree on a branch tip, that tip is pushed everywhere. +- If one branch tip is a descendant of the others, the descendant wins and is pushed everywhere. +- If branch tips diverged, that branch is skipped and reported. +- If `allow_force = true` or `git-sync sync --force` is used, a diverged branch chooses the newest commit timestamp and force-pushes it. + +Branch deletion is not propagated. If a branch exists on one endpoint and is missing elsewhere, it is recreated elsewhere. This avoids accidental data loss in a bidirectional mirror. + +Tags are fetched into provider-specific cache refs and pushed only when the tag object agrees across providers or exists on one side. Divergent tags are skipped and reported. Tag deletion is not propagated. + +## Example Config + +```toml +[[sites]] +name = "github" +provider = "github" +base_url = "https://github.com" +token = { env = "GITHUB_TOKEN" } + +[[sites]] +name = "gitea" +provider = "gitea" +base_url = "https://gitea.example.com" +token = { env = "GITEA_TOKEN" } + +[[mirrors]] +name = "personal" +create_missing = true +visibility = "private" +allow_force = false + +[[mirrors.endpoints]] +site = "github" +kind = "user" +namespace = "hykilpikonna" + +[[mirrors.endpoints]] +site = "gitea" +kind = "user" +namespace = "azalea" +``` + +## Issues and Pull Requests + +Mirroring issues and pull requests is possible, but it is not the same kind of operation as mirroring Git branches. + +Repository Git data has a shared protocol and object model. Issues and pull requests are provider-specific application data. GitHub, GitLab, and Gitea have different fields, permissions, labels, milestones, users, review states, CI metadata, cross-links, attachments, reactions, and webhook/event histories. + +A practical implementation should be designed as a separate feature with explicit tradeoffs: + +- **Issues:** feasible to copy title, body, state, labels, assignees by mapping usernames, milestones, and labels. Comments can be copied, but original authors and timestamps usually need to be represented in the comment body unless the target API supports impersonation. +- **Pull requests / merge requests:** feasible to copy open PR metadata and comments, but the source and target branches must already exist on the target. Review approvals, check statuses, merge queues, and provider-specific refs do not map cleanly. +- **Bidirectional sync:** much harder than one-time migration. You need durable external IDs, per-provider mapping tables, conflict policy for edits on both sides, deletion/close policy, and rate-limit handling. + +Recommended path: keep Git mirroring in this tool's core sync loop, then add an optional `sync-issues` feature with a local state database and provider-specific mappers. Start with one-way issue copy, then add comments, then consider bidirectional updates only after identity and conflict rules are explicit. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ac54ef1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,415 @@ +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Config { + #[serde(default)] + pub sites: Vec, + #[serde(default)] + pub mirrors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SiteConfig { + pub name: String, + pub provider: ProviderKind, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + pub token: TokenConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_username: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + Github, + Gitlab, + Gitea, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenConfig { + Value(String), + Env(String), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MirrorConfig { + pub name: String, + pub endpoints: Vec, + #[serde(default = "default_true")] + pub create_missing: bool, + #[serde(default)] + pub visibility: Visibility, + #[serde(default)] + pub allow_force: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct EndpointConfig { + pub site: String, + pub kind: NamespaceKind, + pub namespace: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NamespaceKind { + User, + Org, + Group, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + #[default] + Private, + Public, +} + +fn default_true() -> bool { + true +} + +impl Config { + pub fn load(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display())) + } + + pub fn load_or_default(path: &Path) -> Result { + if path.exists() { + Self::load(path) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let contents = toml::to_string_pretty(self)?; + let mut file = fs::File::create(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + protect_file(path)?; + Ok(()) + } + + pub fn site(&self, name: &str) -> Option<&SiteConfig> { + self.sites.iter().find(|site| site.name == name) + } + + pub fn upsert_site(&mut self, site: SiteConfig) { + if let Some(existing) = self + .sites + .iter_mut() + .find(|existing| existing.name == site.name) + { + *existing = site; + } else { + self.sites.push(site); + } + } + + pub fn remove_site(&mut self, name: &str) -> Result<()> { + if !self.sites.iter().any(|site| site.name == name) { + bail!("site '{name}' does not exist"); + } + for mirror in &self.mirrors { + if mirror + .endpoints + .iter() + .any(|endpoint| endpoint.site == name) + { + bail!("site '{name}' is still used by mirror '{}'", mirror.name); + } + } + self.sites.retain(|site| site.name != name); + Ok(()) + } + + pub fn upsert_mirror(&mut self, mirror: MirrorConfig) { + if let Some(existing) = self + .mirrors + .iter_mut() + .find(|existing| existing.name == mirror.name) + { + *existing = mirror; + } else { + self.mirrors.push(mirror); + } + } + + pub fn remove_mirror(&mut self, name: &str) -> Result<()> { + let old_len = self.mirrors.len(); + self.mirrors.retain(|mirror| mirror.name != name); + if self.mirrors.len() == old_len { + bail!("mirror '{name}' does not exist"); + } + Ok(()) + } +} + +impl SiteConfig { + pub fn token(&self) -> Result { + match &self.token { + TokenConfig::Value(value) => Ok(value.clone()), + TokenConfig::Env(name) => { + env::var(name).with_context(|| format!("environment variable {name} is not set")) + } + } + } + + pub fn api_base(&self) -> String { + if let Some(api_url) = &self.api_url { + return trim_end(api_url).to_string(); + } + + match self.provider { + ProviderKind::Github => { + if self.base_url.trim_end_matches('/') == "https://github.com" { + "https://api.github.com".to_string() + } else { + format!("{}/api/v3", trim_end(&self.base_url)) + } + } + ProviderKind::Gitlab => format!("{}/api/v4", trim_end(&self.base_url)), + ProviderKind::Gitea => format!("{}/api/v1", trim_end(&self.base_url)), + } + } +} + +impl EndpointConfig { + pub fn label(&self) -> String { + format!("{}:{}:{:?}", self.site, self.namespace, self.kind) + } +} + +pub fn default_config_path() -> PathBuf { + ProjectDirs::from("dev", "git-sync", "git-sync") + .map(|dirs| dirs.config_dir().join("config.toml")) + .unwrap_or_else(|| PathBuf::from("git-sync.toml")) +} + +pub fn default_work_dir() -> PathBuf { + ProjectDirs::from("dev", "git-sync", "git-sync") + .map(|dirs| dirs.cache_dir().join("mirrors")) + .unwrap_or_else(|| PathBuf::from(".git-sync-cache")) +} + +fn trim_end(value: &str) -> &str { + value.trim_end_matches('/') +} + +#[cfg(unix)] +fn protect_file(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let permissions = fs::Permissions::from_mode(0o600); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to set permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn protect_file(_path: &Path) -> Result<()> { + Ok(()) +} + +pub fn validate_config(config: &Config) -> Result<()> { + if config.sites.is_empty() { + bail!("no sites configured"); + } + if config.mirrors.is_empty() { + bail!("no mirror groups configured"); + } + for mirror in &config.mirrors { + if mirror.endpoints.len() < 2 { + bail!( + "mirror '{}' must contain at least two endpoints", + mirror.name + ); + } + for endpoint in &mirror.endpoints { + config.site(&endpoint.site).ok_or_else(|| { + anyhow!( + "mirror '{}' references unknown site '{}'", + mirror.name, + endpoint.site + ) + })?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_token_forms() { + let config: Config = toml::from_str( + r#" + [[sites]] + name = "github" + provider = "github" + base_url = "https://github.com" + token = { env = "GITHUB_TOKEN" } + + [[mirrors]] + name = "personal" + create_missing = true + visibility = "private" + allow_force = false + + [[mirrors.endpoints]] + site = "github" + kind = "user" + namespace = "alice" + + [[mirrors.endpoints]] + site = "github" + kind = "org" + namespace = "example" + "#, + ) + .unwrap(); + + assert_eq!(config.sites.len(), 1); + assert_eq!(config.mirrors[0].endpoints.len(), 2); + } + + #[test] + fn validation_rejects_unknown_sites_and_single_endpoint_groups() { + let config = Config { + sites: vec![site("github", ProviderKind::Github)], + mirrors: vec![MirrorConfig { + name: "broken".to_string(), + endpoints: vec![EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + let err = validate_config(&config).unwrap_err().to_string(); + assert!(err.contains("at least two endpoints")); + + let config = Config { + sites: vec![site("github", ProviderKind::Github)], + mirrors: vec![MirrorConfig { + name: "broken".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "missing".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + let err = validate_config(&config).unwrap_err().to_string(); + assert!(err.contains("unknown site 'missing'")); + } + + #[test] + fn removing_referenced_site_is_rejected() { + let mut config = Config { + sites: vec![ + site("github", ProviderKind::Github), + site("gitea", ProviderKind::Gitea), + ], + mirrors: vec![MirrorConfig { + name: "personal".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + + let err = config.remove_site("github").unwrap_err().to_string(); + assert!(err.contains("still used by mirror 'personal'")); + assert!(config.site("github").is_some()); + } + + #[test] + fn api_base_defaults_match_providers() { + assert_eq!( + site("github", ProviderKind::Github).api_base(), + "https://api.github.com" + ); + assert_eq!( + SiteConfig { + base_url: "https://github.example.test/".to_string(), + ..site("github-enterprise", ProviderKind::Github) + } + .api_base(), + "https://github.example.test/api/v3" + ); + assert_eq!( + SiteConfig { + base_url: "https://gitlab.example.test".to_string(), + ..site("gitlab", ProviderKind::Gitlab) + } + .api_base(), + "https://gitlab.example.test/api/v4" + ); + assert_eq!( + SiteConfig { + base_url: "https://gitea.example.test".to_string(), + ..site("gitea", ProviderKind::Gitea) + } + .api_base(), + "https://gitea.example.test/api/v1" + ); + } + + fn site(name: &str, provider: ProviderKind) -> SiteConfig { + SiteConfig { + name: name.to_string(), + provider, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("token".to_string()), + git_username: None, + } + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..96eb0a6 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,758 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +#[derive(Clone, Debug)] +pub struct RemoteSpec { + pub name: String, + pub url: String, + pub display: String, +} + +#[derive(Clone, Debug)] +pub struct BranchDecision { + pub branch: String, + pub sha: String, + pub source_remotes: Vec, +} + +#[derive(Clone, Debug)] +pub struct BranchConflict { + pub branch: String, + pub tips: Vec<(String, String)>, +} + +#[derive(Clone, Debug)] +pub struct TagDecision { + pub tag: String, + pub sha: String, + pub source_remotes: Vec, +} + +#[derive(Clone, Debug)] +pub struct TagConflict { + pub tag: String, + pub tips: Vec<(String, String)>, +} + +pub struct GitMirror { + path: PathBuf, + redactor: Redactor, + dry_run: bool, +} + +#[derive(Clone, Debug)] +pub struct Redactor { + secrets: Vec, +} + +impl GitMirror { + pub fn open(path: PathBuf, redactor: Redactor, dry_run: bool) -> Result { + if !path.exists() { + if dry_run { + println!("dry-run: git init --bare {}", path.display()); + return Ok(Self { + path, + redactor, + dry_run, + }); + } + fs::create_dir_all(&path)?; + run_plain("git", ["init", "--bare"], Some(&path), &redactor, dry_run) + .with_context(|| format!("failed to initialize {}", path.display()))?; + } + Ok(Self { + path, + redactor, + dry_run, + }) + } + + pub fn configure_remotes(&self, remotes: &[RemoteSpec]) -> Result<()> { + for remote in remotes { + let existing = self.remote_url(&remote.name)?; + match existing { + Some(_) => self.run(["remote", "set-url", &remote.name, &remote.url])?, + None => self.run(["remote", "add", &remote.name, &remote.url])?, + } + } + Ok(()) + } + + pub fn fetch_remote(&self, remote: &RemoteSpec) -> Result<()> { + let branch_refspec = format!("+refs/heads/*:refs/remotes/{}/*", remote.name); + let tag_refspec = format!("+refs/tags/*:refs/remote-tags/{}/*", remote.name); + println!("fetching {}", remote.display); + self.run(["fetch", "--prune", &remote.name, &branch_refspec])?; + self.run(["fetch", "--prune", &remote.name, &tag_refspec]) + } + + pub fn branch_decisions( + &self, + remotes: &[RemoteSpec], + allow_force: bool, + ) -> Result<(Vec, Vec)> { + let mut by_branch: BTreeMap> = BTreeMap::new(); + for remote in remotes { + for (branch, sha) in self.remote_branches(&remote.name)? { + by_branch + .entry(branch) + .or_default() + .push((remote.name.clone(), sha)); + } + } + + let mut decisions = Vec::new(); + let mut conflicts = Vec::new(); + + for (branch, tips) in by_branch { + let unique = tips + .iter() + .map(|(_, sha)| sha.clone()) + .collect::>(); + if unique.len() == 1 { + decisions.push(BranchDecision { + branch, + sha: unique.into_iter().next().unwrap(), + source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(), + }); + continue; + } + + if let Some(winner) = self.fast_forward_winner(unique.iter())? { + let source_remotes = tips + .into_iter() + .filter_map(|(remote, sha)| (sha == winner).then_some(remote)) + .collect(); + decisions.push(BranchDecision { + branch, + sha: winner, + source_remotes, + }); + } else if allow_force { + let winner = self.newest_commit(unique.iter())?; + let source_remotes = tips + .into_iter() + .filter_map(|(remote, sha)| (sha == winner).then_some(remote)) + .collect(); + decisions.push(BranchDecision { + branch, + sha: winner, + source_remotes, + }); + } else { + conflicts.push(BranchConflict { branch, tips }); + } + } + + Ok((decisions, conflicts)) + } + + pub fn tag_decisions( + &self, + remotes: &[RemoteSpec], + ) -> Result<(Vec, Vec)> { + let mut by_tag: BTreeMap> = BTreeMap::new(); + for remote in remotes { + for (tag, sha) in self.remote_tags(&remote.name)? { + by_tag + .entry(tag) + .or_default() + .push((remote.name.clone(), sha)); + } + } + + let mut decisions = Vec::new(); + let mut conflicts = Vec::new(); + + for (tag, tips) in by_tag { + let unique = tips + .iter() + .map(|(_, sha)| sha.clone()) + .collect::>(); + if unique.len() == 1 { + decisions.push(TagDecision { + tag, + sha: unique.into_iter().next().unwrap(), + source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(), + }); + } else { + conflicts.push(TagConflict { tag, tips }); + } + } + + Ok((decisions, conflicts)) + } + + pub fn push_branches( + &self, + remotes: &[RemoteSpec], + branches: &[BranchDecision], + force: bool, + ) -> Result<()> { + for remote in remotes { + for branch in branches { + let refspec = if force { + format!("+{}:refs/heads/{}", branch.sha, branch.branch) + } else { + format!("{}:refs/heads/{}", branch.sha, branch.branch) + }; + println!("pushing {} to {}", branch.branch, remote.display); + self.run(["push", &remote.name, &refspec])?; + } + } + Ok(()) + } + + pub fn push_tags(&self, remotes: &[RemoteSpec], tags: &[TagDecision]) -> Result<()> { + for remote in remotes { + for tag in tags { + let refspec = format!("{}:refs/tags/{}", tag.sha, tag.tag); + println!("pushing tag {} to {}", tag.tag, remote.display); + self.run(["push", &remote.name, &refspec])?; + } + } + Ok(()) + } + + fn remote_url(&self, name: &str) -> Result> { + let output = Command::new("git") + .arg("--git-dir") + .arg(&self.path) + .args(["remote", "get-url", name]) + .output() + .with_context(|| "failed to run git remote get-url")?; + if output.status.success() { + Ok(Some( + String::from_utf8_lossy(&output.stdout).trim().to_string(), + )) + } else { + Ok(None) + } + } + + fn remote_branches(&self, remote: &str) -> Result> { + let prefix = format!("refs/remotes/{remote}/"); + let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?; + let mut branches = Vec::new(); + for line in output.lines() { + let Some((refname, sha)) = line.split_once(' ') else { + continue; + }; + let Some(branch) = refname.strip_prefix(&prefix) else { + continue; + }; + if branch == "HEAD" { + continue; + } + branches.push((branch.to_string(), sha.to_string())); + } + Ok(branches) + } + + fn remote_tags(&self, remote: &str) -> Result> { + let prefix = format!("refs/remote-tags/{remote}/"); + let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?; + let mut tags = Vec::new(); + for line in output.lines() { + let Some((refname, sha)) = line.split_once(' ') else { + continue; + }; + let Some(tag) = refname.strip_prefix(&prefix) else { + continue; + }; + tags.push((tag.to_string(), sha.to_string())); + } + Ok(tags) + } + + fn fast_forward_winner<'a>( + &self, + shas: impl Iterator + Clone, + ) -> Result> { + for candidate in shas.clone() { + let mut is_descendant = true; + for other in shas.clone() { + if candidate == other { + continue; + } + if !self.is_ancestor(other, candidate)? { + is_descendant = false; + break; + } + } + if is_descendant { + return Ok(Some(candidate.clone())); + } + } + Ok(None) + } + + fn newest_commit<'a>(&self, shas: impl Iterator) -> Result { + let mut newest: Option<(i64, String)> = None; + for sha in shas { + let timestamp = self + .output(["show", "-s", "--format=%ct", sha])? + .trim() + .parse::()?; + match &newest { + Some((old, _)) if *old >= timestamp => {} + _ => newest = Some((timestamp, sha.clone())), + } + } + newest + .map(|(_, sha)| sha) + .context("no commits found while choosing force winner") + } + + fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result { + let status = Command::new("git") + .arg("--git-dir") + .arg(&self.path) + .args(["merge-base", "--is-ancestor", ancestor, descendant]) + .status() + .with_context(|| "failed to run git merge-base")?; + match status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => bail!("git merge-base failed for {ancestor} and {descendant}"), + } + } + + fn run(&self, args: [&str; N]) -> Result<()> { + run_plain( + "git", + std::iter::once("--git-dir") + .chain(std::iter::once(self.path.to_str().unwrap())) + .chain(args), + None, + &self.redactor, + self.dry_run, + ) + } + + fn output(&self, args: [&str; N]) -> Result { + if self.dry_run { + return Ok(String::new()); + } + let output = Command::new("git") + .arg("--git-dir") + .arg(&self.path) + .args(args) + .output() + .with_context(|| "failed to run git")?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + bail!( + "git failed: {}", + self.redactor + .redact(&String::from_utf8_lossy(&output.stderr)) + ); + } + } +} + +impl Redactor { + pub fn new(secrets: Vec) -> Self { + let secrets = secrets + .into_iter() + .filter(|secret| !secret.is_empty()) + .collect(); + Self { secrets } + } + + pub fn redact(&self, value: &str) -> String { + let mut redacted = value.to_string(); + for secret in &self.secrets { + redacted = redacted.replace(secret, ""); + } + redacted + } +} + +fn run_plain( + program: &str, + args: I, + current_dir: Option<&Path>, + redactor: &Redactor, + dry_run: bool, +) -> Result<()> +where + I: IntoIterator, + S: AsRef, +{ + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect::>(); + if dry_run { + println!("dry-run: {} {}", program, redactor.redact(&args.join(" "))); + return Ok(()); + } + + let mut command = Command::new(program); + command.args(&args); + if let Some(current_dir) = current_dir { + command.current_dir(current_dir); + } + let output = command + .output() + .with_context(|| format!("failed to run {program}"))?; + if output.status.success() { + Ok(()) + } else { + let stdout = redactor.redact(&String::from_utf8_lossy(&output.stdout)); + let stderr = redactor.redact(&String::from_utf8_lossy(&output.stderr)); + bail!("{program} failed\nstdout: {stdout}\nstderr: {stderr}"); + } +} + +pub fn safe_remote_name(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + use tempfile::TempDir; + + #[test] + fn remote_names_are_git_friendly() { + assert_eq!( + safe_remote_name("github:alice/project"), + "github_alice_project" + ); + } + + #[test] + fn redacts_all_secrets() { + let redactor = Redactor::new(vec!["secret".to_string()]); + assert_eq!( + redactor.redact("https://secret@example.test"), + "https://@example.test" + ); + } + + #[test] + fn branch_decisions_choose_fast_forward_tip() { + let fixture = GitFixture::new(); + let base = fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + let newer = fixture.commit("newer", "newer", 1_700_000_100); + fixture.push_head(&fixture.remote_a, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap(); + + assert!(conflicts.is_empty()); + let main = find_branch(&decisions, "main"); + assert_eq!(main.sha, newer); + assert_eq!(main.source_remotes, vec!["a".to_string()]); + assert_ne!(main.sha, base); + } + + #[test] + fn branch_decisions_report_divergent_tips_without_force() { + let fixture = GitFixture::new(); + let base = fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + + let a_tip = fixture.commit("a", "a", 1_700_000_100); + fixture.push_head(&fixture.remote_a, "main"); + fixture.reset_hard(&base); + let b_tip = fixture.commit("b", "b", 1_700_000_200); + fixture.push_head(&fixture.remote_b, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap(); + + assert!(decisions.is_empty()); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].branch, "main"); + assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &a_tip)); + assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &b_tip)); + } + + #[test] + fn branch_decisions_force_selects_newest_divergent_tip() { + let fixture = GitFixture::new(); + let base = fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + + let older = fixture.commit("older", "older", 1_700_000_100); + fixture.push_head(&fixture.remote_a, "main"); + fixture.reset_hard(&base); + let newer = fixture.commit("newer", "newer", 1_700_000_200); + fixture.push_head(&fixture.remote_b, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), true).unwrap(); + + assert!(conflicts.is_empty()); + let main = find_branch(&decisions, "main"); + assert_eq!(main.sha, newer); + assert_ne!(main.sha, older); + assert_eq!(main.source_remotes, vec!["b".to_string()]); + } + + #[test] + fn push_branches_creates_missing_branch_on_other_remotes() { + let fixture = GitFixture::new(); + let expected = fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap(); + assert!(conflicts.is_empty()); + mirror + .push_branches(&fixture.remotes(), &decisions, false) + .unwrap(); + + assert_eq!( + fixture.remote_ref(&fixture.remote_b, "refs/heads/main"), + expected + ); + } + + #[test] + fn tag_decisions_mirror_matching_or_missing_tags_and_skip_divergent_tags() { + let fixture = GitFixture::new(); + let base = fixture.commit("base", "base", 1_700_000_000); + fixture.tag("v1"); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + fixture.push_tag(&fixture.remote_a, "v1"); + fixture.push_tag(&fixture.remote_b, "v1"); + + let a_tip = fixture.commit("a", "a", 1_700_000_100); + fixture.tag("release"); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_tag(&fixture.remote_a, "release"); + + fixture.delete_tag("release"); + fixture.reset_hard(&base); + let b_tip = fixture.commit("b", "b", 1_700_000_200); + fixture.tag("release"); + fixture.push_head(&fixture.remote_b, "main"); + fixture.push_tag(&fixture.remote_b, "release"); + + fixture.delete_tag("missing-on-b"); + fixture.reset_hard(&a_tip); + fixture.tag("missing-on-b"); + fixture.push_tag(&fixture.remote_a, "missing-on-b"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (tags, conflicts) = mirror.tag_decisions(&fixture.remotes()).unwrap(); + + assert_eq!(find_tag(&tags, "v1").sha, base); + assert_eq!(find_tag(&tags, "missing-on-b").sha, a_tip); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].tag, "release"); + assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &a_tip)); + assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &b_tip)); + + mirror.push_tags(&fixture.remotes(), &tags).unwrap(); + assert_eq!( + fixture.remote_ref(&fixture.remote_b, "refs/tags/missing-on-b"), + a_tip + ); + } + + fn find_branch<'a>(decisions: &'a [BranchDecision], name: &str) -> &'a BranchDecision { + decisions + .iter() + .find(|decision| decision.branch == name) + .unwrap_or_else(|| panic!("missing branch decision for {name}")) + } + + fn find_tag<'a>(decisions: &'a [TagDecision], name: &str) -> &'a TagDecision { + decisions + .iter() + .find(|decision| decision.tag == name) + .unwrap_or_else(|| panic!("missing tag decision for {name}")) + } + + struct GitFixture { + _temp: TempDir, + work: PathBuf, + mirror_path: PathBuf, + remote_a: PathBuf, + remote_b: PathBuf, + } + + impl GitFixture { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let work = temp.path().join("work"); + let mirror_path = temp.path().join("mirror.git"); + let remote_a = temp.path().join("a.git"); + let remote_b = temp.path().join("b.git"); + git(None, ["init", "--bare", remote_a.to_str().unwrap()]); + git(None, ["init", "--bare", remote_b.to_str().unwrap()]); + fs::create_dir_all(&work).unwrap(); + git(Some(&work), ["init"]); + git(Some(&work), ["config", "user.email", "test@example.test"]); + git(Some(&work), ["config", "user.name", "Test User"]); + git(Some(&work), ["checkout", "-b", "main"]); + + Self { + _temp: temp, + work, + mirror_path, + remote_a, + remote_b, + } + } + + fn mirror(&self) -> GitMirror { + let mirror = + GitMirror::open(self.mirror_path.clone(), Redactor::new(Vec::new()), false) + .unwrap(); + mirror.configure_remotes(&self.remotes()).unwrap(); + mirror + } + + fn remotes(&self) -> Vec { + vec![ + RemoteSpec { + name: "a".to_string(), + url: self.remote_a.to_string_lossy().to_string(), + display: "remote a".to_string(), + }, + RemoteSpec { + name: "b".to_string(), + url: self.remote_b.to_string_lossy().to_string(), + display: "remote b".to_string(), + }, + ] + } + + fn fetch_all(&self, mirror: &GitMirror) { + for remote in self.remotes() { + mirror.fetch_remote(&remote).unwrap(); + } + } + + fn commit(&self, message: &str, contents: &str, timestamp: i64) -> String { + let path = self.work.join("file.txt"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{contents}").unwrap(); + git(Some(&self.work), ["add", "file.txt"]); + + let date = format!("@{timestamp} +0000"); + let output = Command::new("git") + .current_dir(&self.work) + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date) + .args(["commit", "-m", message]) + .output() + .unwrap(); + assert_success(&output, "git commit"); + self.head() + } + + fn head(&self) -> String { + git_output(Some(&self.work), ["rev-parse", "HEAD"]) + } + + fn reset_hard(&self, sha: &str) { + git(Some(&self.work), ["reset", "--hard", sha]); + } + + fn push_head(&self, remote: &Path, branch: &str) { + let refspec = format!("HEAD:refs/heads/{branch}"); + git( + Some(&self.work), + ["push", remote.to_str().unwrap(), &refspec], + ); + } + + fn tag(&self, name: &str) { + git(Some(&self.work), ["tag", name]); + } + + fn delete_tag(&self, name: &str) { + let _ = Command::new("git") + .current_dir(&self.work) + .args(["tag", "-d", name]) + .output() + .unwrap(); + } + + fn push_tag(&self, remote: &Path, tag: &str) { + let refspec = format!("refs/tags/{tag}:refs/tags/{tag}"); + git( + Some(&self.work), + ["push", remote.to_str().unwrap(), &refspec], + ); + } + + fn remote_ref(&self, remote: &Path, reference: &str) -> String { + git_output( + None, + [ + "--git-dir", + remote.to_str().unwrap(), + "rev-parse", + reference, + ], + ) + } + } + + fn git(current_dir: Option<&Path>, args: [&str; N]) { + let output = git_command(current_dir, args).output().unwrap(); + assert_success(&output, "git"); + } + + fn git_output(current_dir: Option<&Path>, args: [&str; N]) -> String { + let output = git_command(current_dir, args).output().unwrap(); + assert_success(&output, "git output"); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + fn git_command(current_dir: Option<&Path>, args: [&str; N]) -> Command { + let mut command = Command::new("git"); + command.args(args); + if let Some(current_dir) = current_dir { + command.current_dir(current_dir); + } + command + } + + fn assert_success(output: &std::process::Output, label: &str) { + assert!( + output.status.success(), + "{label} failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8a05111 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,399 @@ +mod config; +mod git; +mod provider; +mod sync; + +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use crate::config::{ + Config, EndpointConfig, NamespaceKind, ProviderKind, SiteConfig, TokenConfig, Visibility, + default_config_path, +}; +use crate::sync::{SyncOptions, sync_all}; + +#[derive(Parser, Debug)] +#[command(name = "git-sync")] +#[command(about = "Mirror repositories between Git hosting providers")] +struct Cli { + #[arg(long, global = true, value_name = "PATH")] + config: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + #[command(subcommand)] + Config(ConfigCommand), + Sync(SyncCommand), +} + +#[derive(Subcommand, Debug)] +enum ConfigCommand { + Init, + #[command(subcommand)] + Site(SiteCommand), + #[command(subcommand)] + Mirror(MirrorCommand), + Show, +} + +#[derive(Subcommand, Debug)] +enum SiteCommand { + Add(SiteAddCommand), + Remove(NameCommand), + List, +} + +#[derive(Subcommand, Debug)] +enum MirrorCommand { + Add(MirrorAddCommand), + Remove(NameCommand), + List, +} + +#[derive(Args, Debug)] +struct NameCommand { + name: String, +} + +#[derive(Args, Debug)] +struct SiteAddCommand { + #[arg(long)] + name: String, + #[arg(long)] + provider: ProviderArg, + #[arg(long, value_name = "URL")] + base_url: String, + #[arg(long, value_name = "URL")] + api_url: Option, + #[arg(long, conflicts_with = "token_env")] + token: Option, + #[arg(long, value_name = "ENV", conflicts_with = "token")] + token_env: Option, + #[arg(long)] + git_username: Option, +} + +#[derive(Args, Debug)] +struct MirrorAddCommand { + #[arg(long)] + name: String, + #[arg(long = "endpoint", required = true, action = clap::ArgAction::Append, value_name = "SITE:KIND:NAMESPACE")] + endpoints: Vec, + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + create_missing: bool, + #[arg(long, default_value_t = VisibilityArg::Private)] + visibility: VisibilityArg, + #[arg(long, default_value_t = false)] + allow_force: bool, +} + +#[derive(Args, Debug)] +struct SyncCommand { + #[arg(long, value_name = "NAME")] + group: Option, + #[arg(long)] + dry_run: bool, + #[arg(long)] + no_create: bool, + #[arg(long)] + force: bool, + #[arg(long, value_name = "PATH")] + work_dir: Option, +} + +#[derive(Clone, Debug, ValueEnum)] +enum ProviderArg { + Github, + Gitlab, + Gitea, +} + +#[derive(Clone, Debug, ValueEnum)] +enum VisibilityArg { + Private, + Public, +} + +impl std::fmt::Display for VisibilityArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Private => write!(f, "private"), + Self::Public => write!(f, "public"), + } + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let config_path = cli.config.unwrap_or_else(default_config_path); + + match cli.command { + Command::Config(command) => handle_config(command, config_path), + Command::Sync(command) => { + let config = Config::load(&config_path) + .with_context(|| format!("failed to load config at {}", config_path.display()))?; + sync_all( + &config, + SyncOptions { + group: command.group, + dry_run: command.dry_run, + create_missing_override: command.no_create.then_some(false), + force_override: command.force.then_some(true), + work_dir: command.work_dir, + }, + ) + } + } +} + +fn handle_config(command: ConfigCommand, path: PathBuf) -> Result<()> { + match command { + ConfigCommand::Init => { + if path.exists() { + bail!("config already exists at {}", path.display()); + } + let config = Config::default(); + config.save(&path)?; + println!("created {}", path.display()); + Ok(()) + } + ConfigCommand::Site(command) => handle_site(command, path), + ConfigCommand::Mirror(command) => handle_mirror(command, path), + ConfigCommand::Show => { + let config = Config::load(&path)?; + println!("{}", toml::to_string_pretty(&config)?); + Ok(()) + } + } +} + +fn handle_site(command: SiteCommand, path: PathBuf) -> Result<()> { + let mut config = Config::load_or_default(&path)?; + match command { + SiteCommand::Add(args) => { + let token = match (args.token, args.token_env) { + (Some(value), None) => TokenConfig::Value(value), + (None, Some(env)) => TokenConfig::Env(env), + (None, None) => bail!("pass either --token or --token-env"), + (Some(_), Some(_)) => unreachable!("clap enforces token conflicts"), + }; + config.upsert_site(SiteConfig { + name: args.name, + provider: args.provider.into(), + base_url: args.base_url, + api_url: args.api_url, + token, + git_username: args.git_username, + }); + config.save(&path)?; + println!("updated {}", path.display()); + Ok(()) + } + SiteCommand::Remove(args) => { + config.remove_site(&args.name)?; + config.save(&path)?; + println!("removed site {}", args.name); + Ok(()) + } + SiteCommand::List => { + for site in &config.sites { + println!("{}\t{:?}\t{}", site.name, site.provider, site.base_url); + } + Ok(()) + } + } +} + +fn handle_mirror(command: MirrorCommand, path: PathBuf) -> Result<()> { + let mut config = Config::load_or_default(&path)?; + match command { + MirrorCommand::Add(args) => { + if args.endpoints.len() < 2 { + bail!("mirror groups need at least two --endpoint values"); + } + let endpoints = args + .endpoints + .iter() + .map(|value| parse_endpoint(value)) + .collect::>>()?; + for endpoint in &endpoints { + config + .site(&endpoint.site) + .with_context(|| format!("unknown site '{}'", endpoint.site))?; + } + config.upsert_mirror(config::MirrorConfig { + name: args.name, + endpoints, + create_missing: args.create_missing, + visibility: args.visibility.into(), + allow_force: args.allow_force, + }); + config.save(&path)?; + println!("updated {}", path.display()); + Ok(()) + } + MirrorCommand::Remove(args) => { + config.remove_mirror(&args.name)?; + config.save(&path)?; + println!("removed mirror {}", args.name); + Ok(()) + } + MirrorCommand::List => { + for mirror in &config.mirrors { + let endpoints = mirror + .endpoints + .iter() + .map(|endpoint| { + format!( + "{}:{:?}:{}", + endpoint.site, endpoint.kind, endpoint.namespace + ) + }) + .collect::>() + .join(", "); + println!("{}\t{}", mirror.name, endpoints); + } + Ok(()) + } + } +} + +fn parse_endpoint(value: &str) -> Result { + let parts = value.splitn(3, ':').collect::>(); + if parts.len() != 3 { + bail!("endpoint must be SITE:KIND:NAMESPACE, got '{value}'"); + } + + let kind = match parts[1].to_ascii_lowercase().as_str() { + "user" => NamespaceKind::User, + "org" | "organization" => NamespaceKind::Org, + "group" => NamespaceKind::Group, + other => bail!("unsupported namespace kind '{other}'"), + }; + + Ok(EndpointConfig { + site: parts[0].to_string(), + kind, + namespace: parts[2].to_string(), + }) +} + +impl From for ProviderKind { + fn from(value: ProviderArg) -> Self { + match value { + ProviderArg::Github => Self::Github, + ProviderArg::Gitlab => Self::Gitlab, + ProviderArg::Gitea => Self::Gitea, + } + } +} + +impl From for Visibility { + fn from(value: VisibilityArg) -> Self { + match value { + VisibilityArg::Private => Self::Private, + VisibilityArg::Public => Self::Public, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_accepts_repeated_mirror_endpoints() { + let cli = Cli::try_parse_from([ + "git-sync", + "config", + "mirror", + "add", + "--name", + "personal", + "--endpoint", + "github:user:hykilpikonna", + "--endpoint", + "gitea:user:azalea", + ]) + .unwrap(); + + let Command::Config(ConfigCommand::Mirror(MirrorCommand::Add(args))) = cli.command else { + panic!("parsed unexpected command"); + }; + assert_eq!(args.name, "personal"); + assert_eq!( + args.endpoints, + vec![ + "github:user:hykilpikonna".to_string(), + "gitea:user:azalea".to_string() + ] + ); + } + + #[test] + fn endpoint_parser_supports_aliases_and_rejects_bad_kinds() { + let endpoint = parse_endpoint("github:organization:MewoLab").unwrap(); + assert_eq!(endpoint.site, "github"); + assert_eq!(endpoint.kind, NamespaceKind::Org); + assert_eq!(endpoint.namespace, "MewoLab"); + + let endpoint = parse_endpoint("gitlab:group:parent/child").unwrap(); + assert_eq!(endpoint.kind, NamespaceKind::Group); + + let err = parse_endpoint("github:team:alice").unwrap_err().to_string(); + assert!(err.contains("unsupported namespace kind 'team'")); + + let err = parse_endpoint("github:user").unwrap_err().to_string(); + assert!(err.contains("SITE:KIND:NAMESPACE")); + } + + #[test] + fn site_add_requires_one_token_source() { + let missing = Cli::try_parse_from([ + "git-sync", + "config", + "site", + "add", + "--name", + "github", + "--provider", + "github", + "--base-url", + "https://github.com", + ]) + .unwrap(); + + let Command::Config(ConfigCommand::Site(SiteCommand::Add(args))) = missing.command else { + panic!("parsed unexpected command"); + }; + let temp = tempfile::TempDir::new().unwrap(); + let err = handle_site(SiteCommand::Add(args), temp.path().join("config.toml")) + .unwrap_err() + .to_string(); + assert!(err.contains("pass either --token or --token-env")); + + let conflict = Cli::try_parse_from([ + "git-sync", + "config", + "site", + "add", + "--name", + "github", + "--provider", + "github", + "--base-url", + "https://github.com", + "--token", + "a", + "--token-env", + "GITHUB_TOKEN", + ]); + assert!(conflict.is_err()); + } +} diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..d475815 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,512 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result, anyhow, bail}; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}; +use serde::Deserialize; +use serde_json::json; +use url::Url; + +use crate::config::{EndpointConfig, NamespaceKind, ProviderKind, SiteConfig, Visibility}; + +#[derive(Clone, Debug)] +pub struct RemoteRepo { + pub name: String, + pub clone_url: String, + pub private: bool, + pub description: Option, +} + +#[derive(Clone, Debug)] +pub struct EndpointRepo { + pub endpoint: EndpointConfig, + pub repo: RemoteRepo, +} + +pub struct ProviderClient<'a> { + site: &'a SiteConfig, + token: String, + http: Client, +} + +impl<'a> ProviderClient<'a> { + pub fn new(site: &'a SiteConfig) -> Result { + let token = site.token()?; + Ok(Self { + site, + token, + http: Client::builder().build()?, + }) + } + + pub fn list_repos(&self, endpoint: &EndpointConfig) -> Result> { + match self.site.provider { + ProviderKind::Github => self.github_list_repos(endpoint), + ProviderKind::Gitlab => self.gitlab_list_repos(endpoint), + ProviderKind::Gitea => self.gitea_list_repos(endpoint), + } + } + + pub fn create_repo( + &self, + endpoint: &EndpointConfig, + name: &str, + visibility: &Visibility, + description: Option<&str>, + ) -> Result { + match self.site.provider { + ProviderKind::Github => { + self.github_create_repo(endpoint, name, visibility, description) + } + ProviderKind::Gitlab => { + self.gitlab_create_repo(endpoint, name, visibility, description) + } + ProviderKind::Gitea => self.gitea_create_repo(endpoint, name, visibility, description), + } + } + + pub fn authenticated_clone_url(&self, clone_url: &str) -> Result { + let mut url = Url::parse(clone_url) + .or_else(|_| Url::parse(&format!("{}/{}", self.site.base_url, clone_url))) + .with_context(|| format!("failed to parse clone URL '{clone_url}'"))?; + if url.scheme() != "https" && url.scheme() != "http" { + bail!("only HTTP(S) clone URLs are supported, got '{clone_url}'"); + } + + let username = self + .site + .git_username + .clone() + .unwrap_or_else(|| match self.site.provider { + ProviderKind::Github => "x-access-token".to_string(), + ProviderKind::Gitlab | ProviderKind::Gitea => "oauth2".to_string(), + }); + url.set_username(&username) + .map_err(|_| anyhow!("failed to set username on clone URL"))?; + url.set_password(Some(&self.token)) + .map_err(|_| anyhow!("failed to set token on clone URL"))?; + Ok(url.to_string()) + } + + fn github_list_repos(&self, endpoint: &EndpointConfig) -> Result> { + match endpoint.kind { + NamespaceKind::User => { + let url = format!( + "{}/user/repos?affiliation=owner&visibility=all&per_page=100", + self.site.api_base() + ); + let repos: Vec = self + .paged_get(&url)? + .into_iter() + .filter(|repo: &GithubRepo| { + repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace) + }) + .collect(); + Ok(repos.into_iter().map(Into::into).collect()) + } + NamespaceKind::Org => { + let url = format!( + "{}/orgs/{}/repos?type=all&per_page=100", + self.site.api_base(), + endpoint.namespace + ); + let repos: Vec = self.paged_get(&url)?; + Ok(repos.into_iter().map(Into::into).collect()) + } + NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"), + } + } + + fn github_create_repo( + &self, + endpoint: &EndpointConfig, + name: &str, + visibility: &Visibility, + description: Option<&str>, + ) -> Result { + let url = match endpoint.kind { + NamespaceKind::User => format!("{}/user/repos", self.site.api_base()), + NamespaceKind::Org => { + format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) + } + NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"), + }; + let body = json!({ + "name": name, + "private": matches!(visibility, Visibility::Private), + "description": description.unwrap_or(""), + }); + self.post_json::(&url, &body).map(Into::into) + } + + fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result> { + match endpoint.kind { + NamespaceKind::User => { + let url = format!( + "{}/users/{}/projects?simple=true&per_page=100&owned=true", + self.site.api_base(), + endpoint.namespace + ); + let repos: Vec = self.paged_get(&url)?; + Ok(repos.into_iter().map(Into::into).collect()) + } + NamespaceKind::Org | NamespaceKind::Group => { + let encoded = urlencoding(&endpoint.namespace); + let url = format!( + "{}/groups/{}/projects?simple=true&include_subgroups=false&per_page=100", + self.site.api_base(), + encoded + ); + let repos: Vec = self.paged_get(&url)?; + Ok(repos.into_iter().map(Into::into).collect()) + } + } + } + + fn gitlab_create_repo( + &self, + endpoint: &EndpointConfig, + name: &str, + visibility: &Visibility, + description: Option<&str>, + ) -> Result { + let mut body = serde_json::Map::from_iter([ + ("name".to_string(), json!(name)), + ("path".to_string(), json!(name)), + ( + "visibility".to_string(), + json!(match visibility { + Visibility::Private => "private", + Visibility::Public => "public", + }), + ), + ("description".to_string(), json!(description.unwrap_or(""))), + ]); + + if matches!(endpoint.kind, NamespaceKind::Org | NamespaceKind::Group) { + let group = self.gitlab_group(&endpoint.namespace)?; + body.insert("namespace_id".to_string(), json!(group.id)); + } + + let url = format!("{}/projects", self.site.api_base()); + self.post_json::(&url, &serde_json::Value::Object(body)) + .map(Into::into) + } + + fn gitlab_group(&self, namespace: &str) -> Result { + let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); + self.get_json(&url) + } + + fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result> { + match endpoint.kind { + NamespaceKind::User => { + let url = format!("{}/user/repos?limit=50", self.site.api_base()); + let repos: Vec = self + .paged_get(&url)? + .into_iter() + .filter(|repo: &GiteaRepo| { + repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace) + }) + .collect(); + Ok(repos.into_iter().map(Into::into).collect()) + } + NamespaceKind::Org => { + let url = format!( + "{}/orgs/{}/repos?limit=50", + self.site.api_base(), + endpoint.namespace + ); + let repos: Vec = self.paged_get(&url)?; + Ok(repos.into_iter().map(Into::into).collect()) + } + NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"), + } + } + + fn gitea_create_repo( + &self, + endpoint: &EndpointConfig, + name: &str, + visibility: &Visibility, + description: Option<&str>, + ) -> Result { + let url = match endpoint.kind { + NamespaceKind::User => format!("{}/user/repos", self.site.api_base()), + NamespaceKind::Org => { + format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) + } + NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"), + }; + let body = json!({ + "name": name, + "private": matches!(visibility, Visibility::Private), + "description": description.unwrap_or(""), + "auto_init": false, + }); + self.post_json::(&url, &body).map(Into::into) + } + + fn paged_get(&self, first_url: &str) -> Result> + where + T: for<'de> Deserialize<'de>, + { + let mut output = Vec::new(); + let mut next_url = Some(first_url.to_string()); + + while let Some(url) = next_url.take() { + let response = self.get(&url)?; + next_url = next_link(response.headers()); + let mut page: Vec = response + .json() + .with_context(|| format!("invalid JSON from {url}"))?; + output.append(&mut page); + } + + Ok(output) + } + + fn get_json(&self, url: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + self.get(url)? + .json() + .with_context(|| format!("invalid JSON from {url}")) + } + + fn post_json(&self, url: &str, body: &serde_json::Value) -> Result + where + T: for<'de> Deserialize<'de>, + { + self.request_headers(self.http.post(url))? + .json(body) + .send() + .with_context(|| format!("POST {url} failed")) + .and_then(|response| check_response("POST", url, response))? + .json() + .with_context(|| format!("invalid JSON from {url}")) + } + + fn get(&self, url: &str) -> Result { + self.request_headers(self.http.get(url))? + .send() + .with_context(|| format!("GET {url} failed")) + .and_then(|response| check_response("GET", url, response)) + } + + fn request_headers( + &self, + request: reqwest::blocking::RequestBuilder, + ) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("git-sync/0.1")); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + match self.site.provider { + ProviderKind::Github => { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", self.token)) + .context("PAT contains invalid header characters")?, + ); + headers.insert( + "X-GitHub-Api-Version", + HeaderValue::from_static("2022-11-28"), + ); + } + ProviderKind::Gitlab => { + headers.insert( + "PRIVATE-TOKEN", + HeaderValue::from_str(&self.token) + .context("PAT contains invalid header characters")?, + ); + } + ProviderKind::Gitea => { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("token {}", self.token)) + .context("PAT contains invalid header characters")?, + ); + } + } + Ok(request.headers(headers)) + } +} + +fn check_response(method: &str, url: &str, response: Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let body = response.text().unwrap_or_default(); + bail!("{method} {url} returned {status}: {body}"); +} + +fn next_link(headers: &HeaderMap) -> Option { + let header = headers.get("link")?.to_str().ok()?; + for part in header.split(',') { + let mut sections = part.trim().split(';'); + let url = sections.next()?.trim(); + let rel = sections.any(|section| section.trim() == "rel=\"next\""); + if rel { + return url + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .map(ToString::to_string); + } + } + None +} + +fn urlencoding(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[derive(Deserialize)] +struct GithubRepo { + name: String, + clone_url: String, + private: bool, + description: Option, + owner: GithubOwner, +} + +#[derive(Deserialize)] +struct GithubOwner { + login: String, +} + +impl From for RemoteRepo { + fn from(value: GithubRepo) -> Self { + Self { + name: value.name, + clone_url: value.clone_url, + private: value.private, + description: value.description, + } + } +} + +#[derive(Deserialize)] +struct GitlabProject { + name: String, + path: Option, + http_url_to_repo: String, + visibility: String, + description: Option, +} + +impl From for RemoteRepo { + fn from(value: GitlabProject) -> Self { + Self { + name: value.path.unwrap_or(value.name), + clone_url: value.http_url_to_repo, + private: value.visibility != "public", + description: value.description, + } + } +} + +#[derive(Deserialize)] +struct GitlabGroup { + id: u64, +} + +#[derive(Deserialize)] +struct GiteaRepo { + name: String, + clone_url: String, + private: bool, + description: Option, + owner: GiteaOwner, +} + +#[derive(Deserialize)] +struct GiteaOwner { + login: String, +} + +impl From for RemoteRepo { + fn from(value: GiteaRepo) -> Self { + Self { + name: value.name, + clone_url: value.clone_url, + private: value.private, + description: value.description, + } + } +} + +pub fn repos_by_name(repos: Vec) -> HashMap> { + let mut output: HashMap> = HashMap::new(); + for repo in repos { + output.entry(repo.repo.name.clone()).or_default().push(repo); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TokenConfig; + + #[test] + fn extracts_next_link() { + let mut headers = HeaderMap::new(); + headers.insert( + "link", + HeaderValue::from_static("; rel=\"next\", ; rel=\"last\""), + ); + assert_eq!(next_link(&headers).unwrap(), "https://example.test?page=2"); + } + + #[test] + fn authenticated_clone_urls_use_provider_defaults() { + let github_site = site(ProviderKind::Github, None); + let github = ProviderClient::new(&github_site).unwrap(); + assert_eq!( + github + .authenticated_clone_url("https://github.com/alice/repo.git") + .unwrap(), + "https://x-access-token:secret@github.com/alice/repo.git" + ); + + let gitlab_site = site(ProviderKind::Gitlab, None); + let gitlab = ProviderClient::new(&gitlab_site).unwrap(); + assert_eq!( + gitlab + .authenticated_clone_url("https://gitlab.example.test/alice/repo.git") + .unwrap(), + "https://oauth2:secret@gitlab.example.test/alice/repo.git" + ); + } + + #[test] + fn authenticated_clone_urls_can_override_git_username() { + let gitea_site = site(ProviderKind::Gitea, Some("mirror-user".to_string())); + let client = ProviderClient::new(&gitea_site).unwrap(); + + assert_eq!( + client + .authenticated_clone_url("https://gitea.example.test/alice/repo.git") + .unwrap(), + "https://mirror-user:secret@gitea.example.test/alice/repo.git" + ); + } + + #[test] + fn group_paths_are_url_encoded_for_gitlab() { + assert_eq!(urlencoding("parent/child group"), "parent%2Fchild+group"); + } + + fn site(provider: ProviderKind, git_username: Option) -> SiteConfig { + SiteConfig { + name: "site".to_string(), + provider, + base_url: "https://example.test".to_string(), + api_url: None, + token: TokenConfig::Value("secret".to_string()), + git_username, + } + } +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..e327bdd --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,296 @@ +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::config::{Config, EndpointConfig, MirrorConfig, default_work_dir, validate_config}; +use crate::git::{GitMirror, Redactor, RemoteSpec, safe_remote_name}; +use crate::provider::{EndpointRepo, ProviderClient, repos_by_name}; + +#[derive(Clone, Debug, Default)] +pub struct SyncOptions { + pub group: Option, + pub dry_run: bool, + pub create_missing_override: Option, + pub force_override: Option, + pub work_dir: Option, +} + +pub fn sync_all(config: &Config, options: SyncOptions) -> Result<()> { + validate_config(config)?; + let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir); + fs::create_dir_all(&work_dir) + .with_context(|| format!("failed to create {}", work_dir.display()))?; + + let mirrors = config + .mirrors + .iter() + .filter(|mirror| { + options + .group + .as_ref() + .is_none_or(|name| mirror.name == *name) + }) + .collect::>(); + if mirrors.is_empty() { + bail!("no mirror group matched"); + } + + let tokens = config + .sites + .iter() + .map(|site| site.token()) + .collect::>>()?; + let redactor = Redactor::new(tokens); + + for mirror in mirrors { + sync_group(config, mirror, &options, &work_dir, redactor.clone())?; + } + + Ok(()) +} + +fn sync_group( + config: &Config, + mirror: &MirrorConfig, + options: &SyncOptions, + work_dir: &Path, + redactor: Redactor, +) -> Result<()> { + println!("syncing mirror group {}", mirror.name); + let create_missing = options + .create_missing_override + .unwrap_or(mirror.create_missing); + let allow_force = options.force_override.unwrap_or(mirror.allow_force); + + let mut all_endpoint_repos = Vec::new(); + for endpoint in &mirror.endpoints { + let site = config.site(&endpoint.site).unwrap(); + let client = ProviderClient::new(site)?; + println!("listing {}", endpoint.label()); + let repos = client + .list_repos(endpoint) + .with_context(|| format!("failed to list repos for {}", endpoint.label()))?; + for repo in repos { + all_endpoint_repos.push(EndpointRepo { + endpoint: endpoint.clone(), + repo, + }); + } + } + + let mut repos = repos_by_name(all_endpoint_repos); + let repo_names = repos.keys().cloned().collect::>(); + if repo_names.is_empty() { + println!("mirror group {} has no repositories", mirror.name); + return Ok(()); + } + + for repo_name in repo_names { + let mut existing = repos.remove(&repo_name).unwrap_or_default(); + ensure_missing_repos( + config, + mirror, + &repo_name, + &mut existing, + create_missing, + options.dry_run, + )?; + if existing.len() < 2 { + println!( + "skipping {}: fewer than two endpoints have this repository", + repo_name + ); + continue; + } + let context = RepoSyncContext { + config, + mirror, + work_dir, + redactor: redactor.clone(), + dry_run: options.dry_run, + allow_force, + }; + sync_repo(&context, &repo_name, &existing)?; + } + + Ok(()) +} + +fn ensure_missing_repos( + config: &Config, + mirror: &MirrorConfig, + repo_name: &str, + existing: &mut Vec, + create_missing: bool, + dry_run: bool, +) -> Result<()> { + let present = existing + .iter() + .map(|repo| repo.endpoint.clone()) + .collect::>(); + let template = existing.first().map(|repo| repo.repo.clone()); + + for endpoint in &mirror.endpoints { + if present.contains(endpoint) { + continue; + } + if !create_missing { + println!( + "{} is missing on {}; creation disabled", + repo_name, + endpoint.label() + ); + continue; + } + + println!("creating {} on {}", repo_name, endpoint.label()); + if dry_run { + continue; + } + + let site = config.site(&endpoint.site).unwrap(); + let client = ProviderClient::new(site)?; + let created = client + .create_repo( + endpoint, + repo_name, + &mirror.visibility, + template + .as_ref() + .and_then(|repo| repo.description.as_deref()), + ) + .with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?; + if created.private != matches!(mirror.visibility, crate::config::Visibility::Private) { + println!( + "created {} on {}, but provider reported a different visibility than requested", + repo_name, + endpoint.label() + ); + } + existing.push(EndpointRepo { + endpoint: endpoint.clone(), + repo: created, + }); + } + + Ok(()) +} + +struct RepoSyncContext<'a> { + config: &'a Config, + mirror: &'a MirrorConfig, + work_dir: &'a Path, + redactor: Redactor, + dry_run: bool, + allow_force: bool, +} + +fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRepo]) -> Result<()> { + println!("syncing repo {}", repo_name); + let endpoint_map = context + .mirror + .endpoints + .iter() + .map(|endpoint| (endpoint.clone(), endpoint)) + .collect::>(); + let mut remotes = Vec::new(); + + for endpoint_repo in repos { + if !endpoint_map.contains_key(&endpoint_repo.endpoint) { + continue; + } + let site = context.config.site(&endpoint_repo.endpoint.site).unwrap(); + let client = ProviderClient::new(site)?; + let remote_name = safe_remote_name(&format!( + "{}_{}", + endpoint_repo.endpoint.site, endpoint_repo.endpoint.namespace + )); + remotes.push(RemoteSpec { + name: remote_name, + url: client.authenticated_clone_url(&endpoint_repo.repo.clone_url)?, + display: endpoint_repo.endpoint.label(), + }); + } + + let path = context + .work_dir + .join(safe_remote_name(&context.mirror.name)) + .join(format!("{}.git", safe_remote_name(repo_name))); + let mirror_repo = GitMirror::open(path, context.redactor.clone(), context.dry_run)?; + mirror_repo.configure_remotes(&remotes)?; + for remote in &remotes { + mirror_repo.fetch_remote(remote)?; + } + + let (branches, conflicts) = mirror_repo.branch_decisions(&remotes, context.allow_force)?; + for conflict in conflicts { + let details = conflict + .tips + .iter() + .map(|(remote, sha)| format!("{remote}@{}", short_sha(sha))) + .collect::>() + .join(", "); + println!( + "conflict in {}/{}: branch {} diverged across {}. Skipping that branch.", + context.mirror.name, repo_name, conflict.branch, details + ); + } + + let (tags, tag_conflicts) = mirror_repo.tag_decisions(&remotes)?; + for conflict in tag_conflicts { + let details = conflict + .tips + .iter() + .map(|(remote, sha)| format!("{remote}@{}", short_sha(sha))) + .collect::>() + .join(", "); + println!( + "conflict in {}/{}: tag {} differs across {}. Skipping that tag.", + context.mirror.name, repo_name, conflict.tag, details + ); + } + + if branches.is_empty() && tags.is_empty() { + println!("{} has no branches or tags to push", repo_name); + return Ok(()); + } + if !branches.is_empty() { + let branch_summary = branches + .iter() + .map(|branch| { + format!( + "{}@{} from {}", + branch.branch, + short_sha(&branch.sha), + branch.source_remotes.join("+") + ) + }) + .collect::>() + .join(", "); + println!("resolved branches for {}: {}", repo_name, branch_summary); + mirror_repo.push_branches(&remotes, &branches, context.allow_force)?; + } + if !tags.is_empty() { + let tag_summary = tags + .iter() + .map(|tag| { + format!( + "{}@{} from {}", + tag.tag, + short_sha(&tag.sha), + tag.source_remotes.join("+") + ) + }) + .collect::>() + .join(", "); + println!("resolved tags for {}: {}", repo_name, tag_summary); + mirror_repo.push_tags(&remotes, &tags)?; + } + Ok(()) +} + +fn short_sha(sha: &str) -> &str { + sha.get(..12).unwrap_or(sha) +}