Re-create this with the canvas option in Gemini 2.5 Pro web chat

```EOF
Create a very minimal and simple tool written in rust which takes in a list of git URLs, and using the gitea api checks if the remote is already mirrored, and if not, then create a repo migration to gitea. I want to basically create a script which can be used to ensure a list of git repos are mirrord to a gitea server.

 The script should take in some command line arguments for;
  - an option to do a dry run, meaning do the check if the repo has to be mirrord, but do not initiate the actual migration
 - path to a TOML configuration file (also can be supplied via an ENV variable)

 The configuration file would have the following information;
   - an API key to be used when talking to the gitea instance we are migrating to
  - the url of the above gitea instance
  - a list of git URLs including an optional rename of the repo name
  - a list of URLs of another git server (gitea, if the API is the same then github, gitlab, etc) that includes the organization name or username. You would clone all repos under that organization/username. For example "https://github.com/hak8or" would be all repos owned by hak8or.

Example toml file;
```
gitea_url = "https://gitmirror.hak8or.com"

api_key = "api_key_goes_here"

repos = [
	{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "cool_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "cool_another_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "rusty_rust", url = "https://github.com/rust-lang/rust.git" },
]
```

Ensure the script is as minimal as possible, do not use libraries if you can avoid them (except clap for CLI arguments, tracing for logging, actix for async and web interactions, reqwest for actual queries, and serde_json for json, or whatever else is commonly used in rust). I will be invoking this tool with a systemd timer.
```EOF
This commit is contained in:
2025-09-22 20:18:21 -04:00
parent 121387dbd2
commit f732535db2
3 changed files with 446 additions and 475 deletions

470
Cargo.lock generated
View File

@@ -67,18 +67,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -102,9 +90,15 @@ dependencies = [
[[package]]
name = "base64"
version = "0.22.1"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
@@ -319,17 +313,6 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.3"
@@ -352,7 +335,6 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
name = "gitea_mirror"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde",
@@ -361,20 +343,19 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "h2"
version = "0.4.12"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
@@ -397,9 +378,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.3.1"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
@@ -408,24 +389,12 @@ dependencies = [
[[package]]
name = "http-body"
version = "1.0.1"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
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",
]
@@ -435,84 +404,47 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2",
"system-configuration",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"windows-registry",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
@@ -638,7 +570,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags",
"bitflags 2.9.4",
"cfg-if",
"libc",
]
@@ -649,16 +581,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -806,7 +728,7 @@ version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags",
"bitflags 2.9.4",
"cfg-if",
"foreign-types",
"libc",
@@ -930,61 +852,47 @@ version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
dependencies = [
"bitflags",
"bitflags 2.9.4",
]
[[package]]
name = "reqwest"
version = "0.12.23"
version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
"winreg",
]
[[package]]
@@ -999,7 +907,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys",
@@ -1007,36 +915,12 @@ dependencies = [
]
[[package]]
name = "rustls"
version = "0.23.32"
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
"base64",
]
[[package]]
@@ -1072,7 +956,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"bitflags 2.9.4",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -1134,11 +1018,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.2"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde_core",
"serde",
]
[[package]]
@@ -1189,6 +1073,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
@@ -1211,12 +1105,6 @@ 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.106"
@@ -1230,12 +1118,9 @@ dependencies = [
[[package]]
name = "sync_wrapper"
version = "1.0.2"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "synstructure"
@@ -1250,20 +1135,20 @@ dependencies = [
[[package]]
name = "system-configuration"
version = "0.6.1"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags",
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
@@ -1276,7 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.0",
@@ -1316,7 +1201,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.59.0",
]
@@ -1342,16 +1227,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -1367,81 +1242,44 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.7"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"indexmap",
"serde_core",
"serde",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.7.2"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde_core",
"serde",
]
[[package]]
name = "toml_parser"
version = "1.0.3"
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.3"
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
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"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower-service"
@@ -1518,12 +1356,6 @@ version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[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.7"
@@ -1688,32 +1520,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-registry"
version = "0.5.3"
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
"windows-targets 0.48.5",
]
[[package]]
@@ -1752,6 +1564,21 @@ dependencies = [
"windows-link 0.2.0",
]
[[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"
@@ -1785,6 +1612,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[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"
@@ -1797,6 +1630,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[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"
@@ -1809,6 +1648,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[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"
@@ -1833,6 +1678,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[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"
@@ -1845,6 +1696,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[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"
@@ -1857,6 +1714,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[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"
@@ -1869,6 +1732,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[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"
@@ -1886,6 +1755,19 @@ name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "wit-bindgen"
@@ -1944,12 +1826,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerotrie"
version = "0.2.2"

View File

@@ -4,13 +4,11 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12.23", features = ["json"] }
clap = { version = "4.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.35", features = ["full"] }
toml = "0.9.7"
toml = "0.8"
tracing = "0.1"
tracing-subscriber = "0.3"
url = "2.5.7"

View File

@@ -1,229 +1,326 @@
use anyhow::{Context, Result};
use clap::Parser;
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tracing::{error, info, warn};
use url::Url;
//! # Gitea Mirror
//!
//! A simple command-line tool to ensure a list of remote git repositories are mirrored to a Gitea instance.
//! It checks if a repository already exists and, if not, creates a mirror migration.
// --- Structs (Unchanged) ---
use clap::Parser;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{Level, error, info, instrument, warn};
use tracing_subscriber;
// Represents the command-line arguments.
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "A simple tool to ensure git repositories are mirrored to Gitea."
)]
struct Cli {
#[arg(short, long, env = "GITEA_MIRROR_CONFIG")]
#[clap(author, version, about, long_about = None)]
struct Args {
/// Path to the TOML configuration file.
#[clap(short, long, value_parser)]
config: PathBuf,
#[arg(long)]
/// Perform a dry run without creating any migrations.
#[clap(short, long)]
dry_run: bool,
}
#[derive(Deserialize, Debug)]
struct RepoToMirror {
// Represents a single repository entry in the config file.
#[derive(Deserialize, Debug, Clone)]
struct RepoConfig {
url: String,
rename: Option<String>,
}
// Represents the main structure of the TOML configuration file.
#[derive(Deserialize, Debug)]
struct Config {
gitea_url: String,
api_key: String,
repos: Vec<RepoToMirror>,
repos: Option<Vec<RepoConfig>>,
organizations: Option<Vec<String>>,
}
// --- Gitea API Structs (Corrected) ---
#[derive(Deserialize, Debug)]
struct GiteaUser {
id: i64,
login: String,
}
// **MODIFIED**: This struct now includes `name` and the correct `mirror_url` field.
#[derive(Deserialize, Debug)]
struct GiteaRepo {
name: String,
mirror: bool,
mirror_url: Option<String>, // The original source URL of the mirror
}
#[derive(Serialize, Debug)]
struct MigrationRequest<'a> {
// Represents the payload for creating a migration in Gitea.
#[derive(serde::Serialize, Debug)]
struct MigrateRepoPayload<'a> {
clone_addr: &'a str,
uid: i64,
repo_name: &'a str,
mirror: bool,
private: bool,
description: String,
description: &'a str,
uid: i64, // The user ID of the owner. We'll fetch this.
}
// Represents a user as returned by the Gitea API.
#[derive(Deserialize, Debug)]
struct GiteaUser {
id: i64,
}
/// Entry point of the application.
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the tracing subscriber for logging.
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
let config_content = fs::read_to_string(&cli.config)
.with_context(|| format!("Failed to read config file at {:?}", cli.config))?;
let config: Config =
toml::from_str(&config_content).context("Failed to parse TOML configuration")?;
// Parse command-line arguments or get config path from environment variable.
let args = match Args::try_parse() {
Ok(args) => args,
Err(_) => {
// If parsing fails, check for the environment variable.
if let Ok(config_path) = env::var("GITEA_MIRROR_CONFIG") {
Args {
config: PathBuf::from(config_path),
// Check for a dry-run env var as well, defaulting to false.
dry_run: env::var("GITEA_MIRROR_DRY_RUN")
.unwrap_or_else(|_| "false".to_string())
.parse()
.unwrap_or(false),
}
} else {
// If no env var, show help and exit.
Args::parse()
}
}
};
if cli.dry_run {
info!("Performing a dry run. No migrations will be created.");
info!("Starting Gitea mirror process. Dry run: {}", args.dry_run);
// Read and parse the configuration file.
let config = load_config(&args.config)?;
let http_client = reqwest::Client::new();
// Fetch the Gitea user ID for the authenticated user.
let user_id = get_gitea_user_id(&http_client, &config.gitea_url, &config.api_key).await?;
info!(
"Successfully authenticated and retrieved user ID: {}",
user_id
);
// Process repositories from the static list.
if let Some(repos) = &config.repos {
for repo_config in repos {
process_repo(
&repo_config.url,
repo_config.rename.as_deref(),
user_id,
&http_client,
&config,
args.dry_run,
)
.await?;
}
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(ACCEPT, "application/json".parse()?);
headers.insert(CONTENT_TYPE, "application/json".parse()?);
headers.insert(USER_AGENT, "gitea-mirror-tool/0.1.0".parse()?);
headers.insert(AUTHORIZATION, format!("token {}", config.api_key).parse()?);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// Process repositories from the organizations/users list.
if let Some(org_urls) = &config.organizations {
for org_url in org_urls {
info!("Fetching repositories from organization: {}", org_url);
match fetch_org_repos(&http_client, org_url, &config.api_key).await {
Ok(repo_urls) => {
info!("Found {} repositories for {}", repo_urls.len(), org_url);
for url in repo_urls {
process_repo(
&url,
None, // No rename support for orgs
user_id,
&http_client,
&config,
args.dry_run,
)
.await?;
}
}
Err(e) => error!("Failed to fetch repos from {}: {}", org_url, e),
}
}
}
info!("🔗 Connecting to Gitea instance at {}", config.gitea_url);
info!("Gitea mirror process completed.");
Ok(())
}
let user_url = format!("{}/api/v1/user", config.gitea_url);
let user = client
.get(&user_url)
/// Loads and parses the TOML configuration file.
#[instrument(skip(path))]
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
info!("Loading configuration from: {:?}", path);
let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
/// Fetches the authenticated user's ID from Gitea.
#[instrument(skip(http_client, gitea_url, api_key))]
async fn get_gitea_user_id(
http_client: &reqwest::Client,
gitea_url: &str,
api_key: &str,
) -> Result<i64, reqwest::Error> {
let url = format!("{}/api/v1/user", gitea_url);
let user: GiteaUser = http_client
.get(&url)
.header("Authorization", format!("token {}", api_key))
.send()
.await?
.error_for_status()?
.json::<GiteaUser>()
.await
.context("Failed to get Gitea user info. Check your API key and Gitea URL.")?;
info!("Authenticated as user '{}' (ID: {})", user.login, user.id);
.json()
.await?;
Ok(user.id)
}
// **MODIFIED**: We now build two sets: one for source URLs and one for existing repo names.
info!("🔍 Fetching all existing repositories to build a local cache...");
let mut existing_mirror_sources: HashSet<String> = HashSet::new();
let mut existing_repo_names: HashSet<String> = HashSet::new();
/// Checks if a repository already exists in Gitea for the user.
#[instrument(skip(http_client, gitea_url, api_key))]
async fn repo_exists(
http_client: &reqwest::Client,
gitea_url: &str,
api_key: &str,
repo_name: &str,
) -> Result<bool, reqwest::Error> {
let url = format!("{}/api/v1/repos/search", gitea_url);
let response: serde_json::Value = http_client
.get(&url)
.query(&[("q", repo_name), ("limit", "1")])
.header("Authorization", format!("token {}", api_key))
.send()
.await?
.error_for_status()?
.json()
.await?;
if let Some(data) = response.get("data").and_then(|d| d.as_array()) {
for repo in data {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
if name.eq_ignore_ascii_case(repo_name) {
return Ok(true);
}
}
}
}
Ok(false)
}
/// Creates a mirror migration in Gitea.
#[instrument(skip(http_client, config, payload))]
async fn create_migration(
http_client: &reqwest::Client,
config: &Config,
payload: &MigrateRepoPayload<'_>,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/migrate", config.gitea_url);
http_client
.post(&url)
.header("Authorization", format!("token {}", config.api_key))
.json(payload)
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Fetches all repository clone URLs from a given Gitea/GitHub organization/user page.
#[instrument(skip(http_client, api_key))]
async fn fetch_org_repos(
http_client: &reqwest::Client,
org_url: &str,
api_key: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// This is a simplified fetcher. It assumes Gitea API compatibility.
// For GitHub, you might need a different base URL and auth method.
let api_url = if org_url.contains("github.com") {
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
format!("https://api.github.com/users/{}/repos", user_or_org)
} else {
// Assuming Gitea-like URL structure
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
format!(
"{}s/{}/repos",
org_url.replace(user_or_org, &format!("api/v1/user")),
user_or_org
)
};
info!("Querying API endpoint: {}", api_url);
let mut repos: Vec<String> = Vec::new();
let mut page = 1;
loop {
let repos_url = format!("{}/api/v1/user/repos", config.gitea_url);
let repos_on_page = client
.get(&repos_url)
.query(&[("limit", "50"), ("page", &page.to_string())])
let response: Vec<serde_json::Value> = http_client
.get(&api_url)
.query(&[("page", page.to_string())])
// For GitHub, a User-Agent is required.
.header("User-Agent", "gitea-mirror-rust-client")
.header("Authorization", format!("token {}", api_key))
.send()
.await?
.error_for_status()?
.json::<Vec<GiteaRepo>>()
.await
.context("Failed to fetch a page of existing repositories.")?;
.json()
.await?;
if repos_on_page.is_empty() {
break;
if response.is_empty() {
break; // No more pages
}
for repo in repos_on_page {
// Add the name of EVERY repo to prevent any name collisions.
existing_repo_names.insert(repo.name);
// If it's a mirror, store its ORIGINAL source URL for an exact match.
if repo.mirror {
if let Some(mirror_url) = repo.mirror_url {
existing_mirror_sources.insert(mirror_url);
}
for repo in response {
if let Some(clone_url) = repo.get("clone_url").and_then(|u| u.as_str()) {
repos.push(clone_url.to_string());
}
}
page += 1;
}
info!(
"Found {} existing repositories and {} configured mirrors.",
existing_repo_names.len(),
existing_mirror_sources.len()
);
Ok(repos)
}
// **MODIFIED**: The main checking logic is now much more robust.
for repo_config in &config.repos {
let url_to_mirror = &repo_config.url;
/// Core logic to process a single repository.
#[instrument(skip(user_id, http_client, config, dry_run))]
async fn process_repo(
repo_url: &str,
rename: Option<&str>,
user_id: i64,
http_client: &reqwest::Client,
config: &Config,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let repo_name = match rename {
Some(name) => name,
None => extract_repo_name(repo_url).ok_or("Could not extract repo name from URL")?,
};
// CHECK 1: Has this exact source URL already been mirrored?
if existing_mirror_sources.contains(url_to_mirror) {
info!(
"Mirror for source URL '{}' already exists. Skipping.",
url_to_mirror
);
continue;
}
info!("Processing repo '{}' -> '{}'", repo_url, repo_name);
// Determine the target name for the new repository.
let target_repo_name = match &repo_config.rename {
Some(name) => name.clone(),
None => get_repo_name_from_url(url_to_mirror).with_context(|| {
format!("Could not parse repo name from URL: {}", url_to_mirror)
})?,
};
// CHECK 2: Will creating this mirror cause a name collision?
if existing_repo_names.contains(&target_repo_name) {
warn!(
"Cannot create mirror for '{}'. A repository named '{}' already exists. Skipping.",
url_to_mirror, target_repo_name
);
continue;
}
// If both checks pass, we are clear to create the migration.
info!(
"Mirror for '{}' not found and name '{}' is available. Needs creation.",
url_to_mirror, target_repo_name
);
if cli.dry_run {
warn!(
"--dry-run enabled, skipping migration for '{}'.",
url_to_mirror
);
continue;
}
let migration_payload = MigrationRequest {
clone_addr: url_to_mirror,
uid: user.id,
repo_name: &target_repo_name,
mirror: true,
private: false,
description: format!("Mirror of {}", url_to_mirror),
};
info!(
"🚀 Creating migration for '{}' as new repo '{}'...",
url_to_mirror, target_repo_name
);
let migrate_url = format!("{}/api/v1/repos/migrate", config.gitea_url);
let response = client
.post(&migrate_url)
.json(&migration_payload)
.send()
.await?;
if response.status().is_success() {
info!("Successfully initiated migration for '{}'.", url_to_mirror);
if repo_exists(http_client, &config.gitea_url, &config.api_key, repo_name).await? {
info!("Repo '{}' already exists. Skipping.", repo_name);
} else {
warn!("Repo '{}' does not exist. Migration needed.", repo_name);
if !dry_run {
info!("Initiating migration for '{}'...", repo_name);
let payload = MigrateRepoPayload {
clone_addr: repo_url,
repo_name,
mirror: true,
private: false, // Defaulting to public, change if needed
description: "",
uid: user_id,
};
if let Err(e) = create_migration(http_client, config, &payload).await {
error!("Failed to create migration for '{}': {}", repo_name, e);
} else {
info!("Successfully started migration for '{}'.", repo_name);
}
} else {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Could not read error body".to_string());
error!(
"Failed to create migration for '{}'. Status: {}. Body: {}",
url_to_mirror, status, error_body
info!(
"Dry run enabled. Skipping actual migration for '{}'.",
repo_name
);
}
}
info!("All tasks completed.");
Ok(())
}
fn get_repo_name_from_url(git_url: &str) -> Option<String> {
Url::parse(git_url)
.ok()
.and_then(|url| url.path_segments()?.last().map(|s| s.to_string()))
.map(|name| name.strip_suffix(".git").unwrap_or(&name).to_string())
/// Extracts a repository name from a git URL (e.g., "https://.../repo.git" -> "repo").
fn extract_repo_name(url: &str) -> Option<&str> {
url.split('/').last().map(|s| s.trim_end_matches(".git"))
}