From 28418e50d9831ffe7864300f6556e3fbe378ef4f Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 16:42:57 +0100 Subject: [PATCH 01/14] feat: start Rust migration --- .gitignore | 3 + Cargo.lock | 2074 ++++++++++++++++++++++++++++++++ Cargo.toml | 32 + package.json | 14 +- scripts/copy-release-binary.sh | 18 + src/cli.rs | 844 +++++++++++++ src/comments.rs | 682 +++++++++++ src/config.rs | 165 +++ src/gh.rs | 931 ++++++++++++++ src/git.rs | 384 ++++++ src/lib.rs | 7 + src/main.rs | 10 + src/server.rs | 1038 ++++++++++++++++ src/webassets.rs | 5 + tests/diff_parity.rs | 283 +++++ 15 files changed, 6483 insertions(+), 7 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100755 scripts/copy-release-binary.sh create mode 100644 src/cli.rs create mode 100644 src/comments.rs create mode 100644 src/config.rs create mode 100644 src/gh.rs create mode 100644 src/git.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/server.rs create mode 100644 src/webassets.rs create mode 100644 tests/diff_parity.rs diff --git a/.gitignore b/.gitignore index c33ad40..a63a015 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ lerna-debug.log* *.prof *.out +# Rust +/target/ + # Local configuration .diffs/ .env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a156a44 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2074 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "diffs" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "futures-util", + "git2", + "hex", + "hyper", + "hyper-util", + "notify", + "open", + "rand", + "rust-embed", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "toml", + "tower-http", + "url", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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 = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +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 = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[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 = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[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 = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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 = "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 = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "axum", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[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 = "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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +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 = "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" + +[[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 = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "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 = [ + "log", + "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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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 = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +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 = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[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.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "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..7e9b52b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "diffs" +version = "0.1.0" +edition = "2024" +description = "Review local diffs and GitHub pull requests in a browser" +license = "MIT" + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +futures-util = "0.3" +git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } +hex = "0.4" +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["service", "tokio"] } +notify = "8" +open = "5" +rand = "0.9" +rust-embed = { version = "8", features = ["axum"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-stream = { version = "0.1", features = ["sync"] } +toml = "0.9" +tower-http = { version = "0.6", features = ["fs"] } +url = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/package.json b/package.json index 6183cbf..1b6ca3b 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "type": "module", "packageManager": "pnpm@10.33.2", "scripts": { - "build": "pnpm --dir web build && rm -rf internal/webassets/dist && cp -R web/dist internal/webassets/dist && touch internal/webassets/dist/.gitkeep && go build -o bin/diffs ./cmd/diffs", - "dev": "go run ./cmd/diffs", - "format": "pnpm format:go && pnpm format:web", - "format:go": "gofmt -w $(git ls-files '*.go' ':!:internal/webassets/dist/**')", + "build": "pnpm --dir web build && cargo build --release && scripts/copy-release-binary.sh", + "dev": "cargo run --", + "format": "pnpm format:rust && pnpm format:web", + "format:rust": "cargo fmt", "format:web": "pnpm --dir web format", - "lint": "pnpm lint:web && pnpm lint:go", - "lint:go": "go tool golangci-lint run", + "lint": "pnpm lint:web && pnpm lint:rust", + "lint:rust": "cargo clippy -- -D warnings", "lint:web": "pnpm --dir web lint", - "test": "pnpm --dir web lint && pnpm --dir web build && rm -rf internal/webassets/dist && cp -R web/dist internal/webassets/dist && touch internal/webassets/dist/.gitkeep && go test ./..." + "test": "pnpm --dir web lint && pnpm --dir web build && cargo test" } } diff --git a/scripts/copy-release-binary.sh b/scripts/copy-release-binary.sh new file mode 100755 index 0000000..8093d41 --- /dev/null +++ b/scripts/copy-release-binary.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +src="target/release/diffs" +dest="bin/diffs" + +mkdir -p bin +cp "$src" "$dest" + +if [ "$(uname -s)" = "Darwin" ]; then + if command -v xattr >/dev/null 2>&1; then + xattr -d com.apple.provenance "$dest" 2>/dev/null || true + xattr -d com.apple.quarantine "$dest" 2>/dev/null || true + fi + if command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$dest" >/dev/null 2>&1 || true + fi +fi diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..05d4599 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,844 @@ +use crate::{comments, config, gh, git, server}; +use anyhow::{Context, bail}; +use clap::{Args, Parser, Subcommand}; +use std::{ + io::{self, IsTerminal, Read, Write}, + net::{SocketAddr, TcpListener}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 3433; +const RELOAD_DEBOUNCE: Duration = Duration::from_millis(500); + +/// Error that signals a non-zero exit without printing anything (help/diagnostics +/// were already written). Mirrors Go's `quietError`. +#[derive(Debug)] +pub struct QuietExit; + +impl std::fmt::Display for QuietExit { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} + +impl std::error::Error for QuietExit {} + +#[derive(Parser)] +#[command(name = "diffs")] +#[command(about = "Review local diffs and GitHub pull requests in a browser")] +struct Cli { + #[arg(long, default_value = ".")] + dir: PathBuf, + #[command(flatten)] + serve: ServeFlags, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Args)] +struct ServeFlags { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + #[arg(long, default_value_t = DEFAULT_PORT)] + port: u16, + #[arg(long)] + no_open: bool, +} + +#[derive(Subcommand)] +enum Command { + #[command(about = "Review a GitHub pull request")] + Pr { + target: Option, + #[arg(long)] + gh_host: Option, + #[command(flatten)] + serve: ServeFlags, + }, + #[command(about = "Review commits on the current branch against a base")] + Branch { + base: Option, + #[arg(long)] + include_dirty: bool, + #[command(flatten)] + serve: ServeFlags, + }, + #[command(about = "Manage local review comments")] + Comments(CommentsCommand), + #[command(about = "Print version information")] + Version, +} + +#[derive(Args)] +struct CommentsCommand { + #[arg(long)] + json: bool, + #[command(subcommand)] + command: CommentSubcommand, +} + +#[derive(Subcommand)] +enum CommentSubcommand { + #[command(about = "List local comment threads for the current branch")] + List, + #[command(about = "Create a local comment thread")] + Add { + #[arg(long = "file")] + path: String, + #[arg(long)] + line: u32, + #[arg(long, default_value = comments::DEFAULT_SIDE)] + side: String, + #[arg(long)] + end_line: Option, + #[arg(long, default_value = "")] + end_side: String, + #[arg(long)] + body: String, + #[arg(long, default_value = "")] + author: String, + }, + #[command(about = "Reply to a local comment thread")] + Reply { + thread_id: String, + #[arg(long)] + body: String, + #[arg(long, default_value = "")] + author: String, + }, + #[command(about = "Resolve a local comment thread")] + Resolve { thread_id: String }, + #[command(about = "Reopen a resolved local comment thread")] + Reopen { thread_id: String }, +} + +pub async fn run(started: Instant) -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + None => { + run_server_target(&cli.dir, &cli.serve, gh_host(None), "/local", true, started).await + } + Some(Command::Pr { + target, + gh_host: host, + serve, + }) => { + let args = target.into_iter().collect::>(); + let target = gh::pr_target_from_args(&args, &cli.dir).await?; + let host = gh_host(host).or_else(|| (!target.host.is_empty()).then_some(target.host)); + run_server_target(&cli.dir, &serve, host, &target.path, false, started).await + } + Some(Command::Branch { + base, + include_dirty, + serve, + }) => { + // Fail with the formatted git help before inferring a base, so a + // non-repo gives the same UX as the local command (not a confusing + // "could not infer base ref"). + resolve_repo_root_or_help(&cli.dir)?; + let base = resolve_branch_base(base, &cli.dir).await?; + let target = branch_target(&base, include_dirty); + run_server_target(&cli.dir, &serve, gh_host(None), &target, true, started).await + } + Some(Command::Comments(command)) => run_comments(&cli.dir, command), + Some(Command::Version) => { + println!("{}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + } +} + +async fn run_server_target( + dir: &Path, + flags: &ServeFlags, + github_host: Option, + target_path: &str, + watch: bool, + started: Instant, +) -> anyhow::Result<()> { + let stdout_color = colors_enabled(io::stdout().is_terminal()); + let cwd = if watch { + resolve_repo_root_or_help(dir)? + } else { + dir.to_path_buf() + }; + // Canonicalize for a clean, symlink-resolved display (no libgit2 trailing + // slash) that also matches the cwd the server resolves internally. + let cwd = std::fs::canonicalize(&cwd)?; + let app_cfg = config::load_default().context("load config")?; + let on_change: Option = + watch.then(|| new_reload_logger(stdout_color) as server::OnChange); + let running = server::new(server::ServerConfig { + cwd: cwd.clone(), + github_host: github_host.unwrap_or_else(|| gh::DEFAULT_GITHUB_HOST.to_string()), + ui: app_cfg.ui, + watch, + on_change, + })?; + let (listener, requested, actual) = bind_with_fallback(&flags.host, flags.port)?; + let url = browser_url(actual, target_path); + if requested.port() != 0 && requested != actual { + print_port_fallback(&requested.to_string(), &actual.to_string(), stdout_color); + } + print_startup( + &StartupInfo { + url: &url, + target: &target_label(target_path, &cwd), + cwd: &cwd.display().to_string(), + watching: watch, + elapsed: started.elapsed(), + }, + stdout_color, + ); + if !flags.no_open + && let Err(err) = open::that(&url) + { + eprintln!("warning: could not open browser: {err}"); + } + let listener = tokio::net::TcpListener::from_std(listener)?; + server::serve_router(listener, running.router).await +} + +// --- Terminal output (ported from cmd/diffs/output.go) --- + +struct Colors { + reset: &'static str, + dim: &'static str, + green: &'static str, + cyan: &'static str, + yellow: &'static str, + red: &'static str, + magenta: &'static str, +} + +fn colors_enabled(is_terminal: bool) -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + if std::env::var("TERM").is_ok_and(|term| term == "dumb") { + return false; + } + is_terminal +} + +fn palette(enabled: bool) -> Colors { + if enabled { + Colors { + reset: "\x1b[0m", + dim: "\x1b[2m", + green: "\x1b[32m", + cyan: "\x1b[36m", + yellow: "\x1b[33m", + red: "\x1b[31m", + magenta: "\x1b[35m", + } + } else { + Colors { + reset: "", + dim: "", + green: "", + cyan: "", + yellow: "", + red: "", + magenta: "", + } + } +} + +fn colorize(text: &str, color: &str, reset: &str) -> String { + if color.is_empty() { + text.to_string() + } else { + format!("{color}{text}{reset}") + } +} + +fn log_line(c: &Colors, label: &str, message: &str, color: &str) -> String { + let color = if color.is_empty() { c.green } else { color }; + format!(" {color}{label:<8}{} {message}", c.reset) +} + +struct StartupInfo<'a> { + url: &'a str, + target: &'a str, + cwd: &'a str, + watching: bool, + elapsed: Duration, +} + +fn print_startup(info: &StartupInfo<'_>, color: bool) { + let c = palette(color); + println!(); + println!( + "{}", + log_line( + &c, + "diffs", + &format!("ready in {}", format_ready_duration(info.elapsed)), + "", + ) + ); + println!( + "{}", + log_line(&c, "serve", &colorize(info.url, c.cyan, c.reset), "") + ); + println!("{}", log_line(&c, "target", info.target, "")); + if info.watching { + println!("{}", log_line(&c, "watch", info.cwd, "")); + } + println!( + "{}", + log_line(&c, "stop", &colorize("Ctrl+C", c.dim, c.reset), "") + ); + println!(); +} + +fn print_port_fallback(requested: &str, actual: &str, color: bool) { + let c = palette(color); + println!(); + println!( + "{}", + log_line( + &c, + "warn", + &format!("{requested} in use; using {actual}"), + c.yellow + ) + ); +} + +fn print_local_git_help(dir: &str, color: bool) { + let c = palette(color); + eprintln!(); + eprintln!( + "{}", + log_line(&c, "error", &format!("not a git repository: {dir}"), "") + ); + eprintln!("{}", log_line(&c, "hint", "run from a git repository", "")); + eprintln!( + "{}", + log_line(&c, "hint", "or pass --dir /path/to/repo", "") + ); + eprintln!( + "{}", + log_line(&c, "hint", "or use diffs pr /org/repo/pull/123", "") + ); + eprintln!(); +} + +fn format_ready_duration(elapsed: Duration) -> String { + let ms = (elapsed.as_secs_f64() * 1000.0).round() as i64; + format!("{} ms", ms.max(1)) +} + +fn new_reload_logger(color: bool) -> server::OnChange { + let last: Arc>> = Arc::new(Mutex::new(None)); + Arc::new(move |files: Vec| { + let now = Instant::now(); + { + let mut guard = last.lock().expect("reload logger lock poisoned"); + if let Some(prev) = *guard + && now.duration_since(prev) < RELOAD_DEBOUNCE + { + return; + } + *guard = Some(now); + } + print_reload(&files, color); + }) +} + +fn print_reload(files: &[git::ChangedFile], color: bool) { + let c = palette(color); + let (label, message) = reload_line(files, &c, color); + println!( + "{}", + log_line(&c, &label, &message, reload_label_color(&label, &c)) + ); +} + +fn reload_line(files: &[git::ChangedFile], c: &Colors, color: bool) -> (String, String) { + let Some(first) = files.first() else { + return ("change".to_string(), "local changes".to_string()); + }; + let label = first.action.as_str().to_string(); + let path = if color { + colorize(&first.path, c.cyan, c.reset) + } else { + first.path.clone() + }; + if files.len() == 1 { + (label, path) + } else { + (label, format!("{path} (+{} more)", files.len() - 1)) + } +} + +fn reload_label_color(label: &str, c: &Colors) -> &'static str { + match label { + "added" => c.green, + "modified" => c.yellow, + "deleted" => c.red, + "renamed" => c.magenta, + _ => c.green, + } +} + +/// Builds the human label for the served target (ported from `targetLabel`). +fn target_label(target_path: &str, cwd: &std::path::Path) -> String { + if target_path == "/local" { + let branch = git::branch(cwd); + return if branch.is_empty() { + "local repository".to_string() + } else { + branch + }; + } + if target_path.starts_with("/branch") { + let base = branch_base_from_target(target_path); + let head = git::branch(cwd); + let head = if head.is_empty() { "HEAD" } else { &head }; + return if base.is_empty() { + format!("{head} branch diff") + } else { + format!("{head} -> {base}") + }; + } + let parts: Vec<&str> = target_path.trim_matches('/').split('/').collect(); + if parts.len() == 4 && parts[2] == "pull" { + return format!("GitHub PR {}/{}#{}", parts[0], parts[1], parts[3]); + } + target_path.to_string() +} + +fn branch_base_from_target(target_path: &str) -> String { + let Some((_, query)) = target_path.split_once('?') else { + return String::new(); + }; + url::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == "base") + .map(|(_, value)| value.into_owned()) + .unwrap_or_default() +} + +fn bind_with_fallback( + host: &str, + port: u16, +) -> anyhow::Result<(TcpListener, SocketAddr, SocketAddr)> { + let host = if host.trim().is_empty() || host.trim() == "localhost" { + DEFAULT_HOST + } else { + host.trim() + }; + let requested: SocketAddr = format!("{host}:{port}").parse()?; + match TcpListener::bind(requested) { + Ok(listener) => { + listener.set_nonblocking(true)?; + let actual = listener.local_addr()?; + Ok((listener, requested, actual)) + } + Err(err) if err.kind() == io::ErrorKind::AddrInUse && port != 0 => { + let fallback: SocketAddr = format!("{host}:0").parse()?; + let listener = TcpListener::bind(fallback)?; + listener.set_nonblocking(true)?; + let actual = listener.local_addr()?; + Ok((listener, requested, actual)) + } + Err(err) => Err(err.into()), + } +} + +fn browser_url(addr: SocketAddr, target_path: &str) -> String { + let host = match addr.ip().to_string().as_str() { + "0.0.0.0" | "::" => DEFAULT_HOST.to_string(), + value => value.to_string(), + }; + format!("http://{host}:{}{}", addr.port(), target_path) +} + +async fn resolve_branch_base(base: Option, dir: &PathBuf) -> anyhow::Result { + if let Some(base) = base + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Ok(base); + } + for args in [ + ["pr", "view", "--json", "baseRefName", "-q", ".baseRefName"], + [ + "repo", + "view", + "--json", + "defaultBranchRef", + "-q", + ".defaultBranchRef.name", + ], + ] { + if let Ok(candidate) = gh::run(dir, &args).await + && let Some(resolved) = git::resolve_local_ref(dir, &candidate) + { + return Ok(resolved); + } + } + for candidate in ["main", "master"] { + if let Some(resolved) = git::resolve_local_ref(dir, candidate) { + return Ok(resolved); + } + } + bail!("could not infer base ref; pass one explicitly, e.g. `diffs branch main`") +} + +fn branch_target(base: &str, include_dirty: bool) -> String { + let mut params = url::form_urlencoded::Serializer::new(String::new()); + params.append_pair("base", base); + if include_dirty { + params.append_pair("dirty", "1"); + } + format!("/branch?{}", params.finish()) +} + +/// Resolves the repository working-tree root, or prints the formatted git help +/// to stderr and returns `QuietExit` so the caller exits 1 without a duplicate +/// error line. Shared by the local and branch commands. +fn resolve_repo_root_or_help(dir: &Path) -> anyhow::Result { + match git::root(dir) { + Ok(root) => Ok(root), + Err(_) => { + print_local_git_help( + &dir.display().to_string(), + colors_enabled(io::stderr().is_terminal()), + ); + Err(QuietExit.into()) + } + } +} + +fn gh_host(flag: Option) -> Option { + flag.or_else(|| std::env::var("GH_HOST").ok()) + .map(|host| host.trim().to_string()) + .filter(|host| !host.is_empty()) +} + +fn run_comments(dir: &PathBuf, command: CommentsCommand) -> anyhow::Result<()> { + let store = comments::Store::new(dir)?; + match command.command { + CommentSubcommand::List => { + let threads = store.list()?; + if command.json { + print_json(&serde_json::json!({ "threads": threads }))?; + } else { + print_threads(&threads); + } + } + CommentSubcommand::Add { + path, + line, + side, + end_line, + end_side, + body, + author, + } => { + let thread = store.add_thread(comments::AddThreadInput { + path, + side, + line, + end_line: end_line.unwrap_or_default(), + end_side, + body: body_from_flag(body)?, + author, + })?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Reply { + thread_id, + body, + author, + } => { + let thread = store.add_reply( + &thread_id, + comments::AddReplyInput { + body: body_from_flag(body)?, + author, + }, + )?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Resolve { thread_id } => { + let thread = store.resolve(&thread_id)?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Reopen { thread_id } => { + let thread = store.reopen(&thread_id)?; + print_thread_result(&thread, command.json)?; + } + } + Ok(()) +} + +fn body_from_flag(body: String) -> anyhow::Result { + if body != "-" { + return Ok(body); + } + let mut data = String::new(); + io::stdin().read_to_string(&mut data)?; + Ok(data) +} + +fn print_json(value: &serde_json::Value) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_thread_result(thread: &comments::Thread, as_json: bool) -> anyhow::Result<()> { + if as_json { + println!("{}", serde_json::to_string_pretty(thread)?); + } else { + println!( + "{}\t{}\t{}\t{}", + thread.id, + thread.status, + thread_location(thread), + latest_comment_body(thread) + ); + } + Ok(()) +} + +fn print_threads(threads: &[comments::Thread]) { + if threads.is_empty() { + println!("No local comment threads."); + return; + } + println!("ID\tSTATUS\tLOCATION\tCOMMENTS\tLATEST"); + for thread in threads { + println!( + "{}\t{}\t{}\t{}\t{}", + thread.id, + thread.status, + thread_location(thread), + thread.comments.len(), + latest_comment_body(thread) + ); + } + let _ = io::stdout().flush(); +} + +fn thread_location(thread: &comments::Thread) -> String { + let end_line = if thread.end_line == 0 { + thread.line + } else { + thread.end_line + }; + if end_line == thread.line { + format!("{}:{}", thread.path, thread.line) + } else { + format!("{}:{}-{end_line}", thread.path, thread.line) + } +} + +fn latest_comment_body(thread: &comments::Thread) -> String { + const LIMIT: usize = 72; + let Some(comment) = thread.comments.last() else { + return String::new(); + }; + let body = comment.body.replace('\n', " "); + let mut chars = body.chars(); + let preview: String = chars.by_ref().take(LIMIT - 3).collect(); + if chars.next().is_some() { + format!("{preview}...") + } else { + body + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener as StdTcpListener; + + fn run_git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + #[test] + fn branch_target_encodes_base_and_dirty() { + assert_eq!( + branch_target("origin/main", false), + "/branch?base=origin%2Fmain" + ); + assert_eq!( + branch_target("origin/main", true), + "/branch?base=origin%2Fmain&dirty=1" + ); + } + + #[test] + fn branch_base_from_target_decodes_base() { + assert_eq!( + branch_base_from_target("/branch?base=origin%2Fmain"), + "origin/main" + ); + assert_eq!(branch_base_from_target("/branch?base=main&dirty=1"), "main"); + assert_eq!(branch_base_from_target("/local"), ""); + } + + #[test] + fn browser_url_uses_loopback_for_wildcard() { + let addr: SocketAddr = "0.0.0.0:3433".parse().unwrap(); + assert_eq!(browser_url(addr, "/local"), "http://127.0.0.1:3433/local"); + } + + #[test] + fn target_label_variants() { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init"]); + run_git(dir.path(), &["checkout", "-b", "feature/startup"]); + + assert_eq!(target_label("/local", dir.path()), "feature/startup"); + assert_eq!( + target_label("/branch?base=origin%2Fmain", dir.path()), + "feature/startup -> origin/main" + ); + assert_eq!( + target_label("/org/repo/pull/123", dir.path()), + "GitHub PR org/repo#123" + ); + assert_eq!( + target_label("/local", &dir.path().join("missing")), + "local repository" + ); + } + + fn changed(action: git::ChangeAction, path: &str) -> git::ChangedFile { + git::ChangedFile { + path: path.to_string(), + action, + } + } + + #[test] + fn reload_line_summarizes_multiple_paths() { + let files = [ + changed(git::ChangeAction::Added, "a.go"), + changed(git::ChangeAction::Modified, "b.go"), + changed(git::ChangeAction::Deleted, "c.go"), + ]; + let (label, message) = reload_line(&files, &palette(false), false); + assert_eq!(label, "added"); + assert_eq!(message, "a.go (+2 more)"); + } + + #[test] + fn reload_line_colors_single_path() { + let colors = Colors { + reset: "Z", + cyan: "C", + dim: "", + green: "", + yellow: "", + red: "", + magenta: "", + }; + let files = [changed(git::ChangeAction::Modified, "a.go")]; + let (label, message) = reload_line(&files, &colors, true); + assert_eq!(label, "modified"); + assert_eq!(message, "Ca.goZ"); + } + + #[test] + fn reload_line_falls_back_to_change_label() { + let (label, message) = reload_line(&[], &palette(false), false); + assert_eq!(label, "change"); + assert_eq!(message, "local changes"); + } + + #[test] + fn reload_label_color_by_action() { + let c = palette(true); + assert_eq!(reload_label_color("added", &c), c.green); + assert_eq!(reload_label_color("modified", &c), c.yellow); + assert_eq!(reload_label_color("deleted", &c), c.red); + assert_eq!(reload_label_color("renamed", &c), c.magenta); + assert_eq!(reload_label_color("change", &c), c.green); + } + + #[test] + fn format_ready_duration_has_floor_of_one_ms() { + assert_eq!(format_ready_duration(Duration::from_millis(0)), "1 ms"); + assert_eq!(format_ready_duration(Duration::from_micros(200)), "1 ms"); + assert_eq!(format_ready_duration(Duration::from_millis(7)), "7 ms"); + } + + #[test] + fn latest_comment_body_truncates_utf8_safely() { + let body = "评".repeat(80) + " done"; + let thread = comments::Thread { + id: "t".into(), + provider: "local".into(), + branch: "main".into(), + path: "a.go".into(), + side: "additions".into(), + line: 1, + end_side: String::new(), + end_line: 0, + status: "open".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + comments: vec![comments::Comment { + id: "c".into(), + author: "a".into(), + body, + created_at: chrono::Utc::now(), + }], + reply_to_id: None, + url: String::new(), + }; + let got = latest_comment_body(&thread); + assert!(got.is_char_boundary(got.len())); + assert_eq!(got.matches('评').count(), 69); + assert!(got.ends_with("...")); + } + + #[test] + fn resolve_repo_root_or_help_quiet_exits_outside_repo() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_repo_root_or_help(dir.path()).unwrap_err(); + assert!( + err.downcast_ref::().is_some(), + "expected QuietExit, got: {err}" + ); + } + + #[test] + fn resolve_repo_root_or_help_returns_root_in_repo() { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init"]); + let root = resolve_repo_root_or_help(dir.path()).unwrap(); + assert_eq!( + root.canonicalize().unwrap(), + dir.path().canonicalize().unwrap() + ); + } + + #[test] + fn bind_with_fallback_uses_random_port_when_busy() { + let busy = StdTcpListener::bind("127.0.0.1:0").unwrap(); + let port = busy.local_addr().unwrap().port(); + + let (listener, requested, actual) = bind_with_fallback("127.0.0.1", port).unwrap(); + assert_eq!(requested.port(), port); + assert_ne!(actual.port(), 0); + assert_ne!(actual.port(), port); + drop(listener); + } +} diff --git a/src/comments.rs b/src/comments.rs new file mode 100644 index 0000000..acc85ba --- /dev/null +++ b/src/comments.rs @@ -0,0 +1,682 @@ +use crate::git; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Component, Path, PathBuf}, + sync::Mutex, +}; +use thiserror::Error; + +pub const DEFAULT_AUTHOR: &str = "local"; +pub const DEFAULT_SIDE: &str = "additions"; + +#[derive(Debug, Error)] +pub enum CommentError { + #[error("comment thread not found")] + NotFound, + #[error("{0}")] + Validation(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Git(#[from] git::GitError), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Serialize, Deserialize)] +pub struct File { + pub version: u8, + pub repo: String, + #[serde(default)] + pub threads: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Thread { + pub id: String, + pub provider: String, + pub branch: String, + pub path: String, + pub side: String, + pub line: u32, + #[serde(skip_serializing_if = "String::is_empty", default)] + pub end_side: String, + #[serde(skip_serializing_if = "is_zero", default)] + pub end_line: u32, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub comments: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub reply_to_id: Option, + #[serde(skip_serializing_if = "String::is_empty", default)] + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Comment { + pub id: String, + pub author: String, + pub body: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddThreadInput { + pub path: String, + pub side: String, + pub line: u32, + #[serde(default)] + pub end_side: String, + #[serde(default)] + pub end_line: u32, + pub body: String, + #[serde(default)] + pub author: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AddReplyInput { + pub body: String, + #[serde(default)] + pub author: String, +} + +pub struct Store { + root: PathBuf, + path: PathBuf, + lock: Mutex<()>, +} + +impl Store { + pub fn new(cwd: impl AsRef) -> Result { + let root = git::root(cwd)?; + let path = root.join(".diffs").join("comments.json"); + Ok(Self { + root, + path, + lock: Mutex::new(()), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn branch(&self) -> String { + let branch = git::branch(&self.root); + if branch.is_empty() { + "local".to_string() + } else { + branch + } + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let file = self.load()?; + let branch = self.branch(); + Ok(file + .threads + .into_iter() + .filter(|thread| thread.branch == branch) + .collect()) + } + + pub fn add_thread(&self, input: AddThreadInput) -> Result { + let clean = clean_thread_input(input.clone())?; + let author = clean_author(&self.root, input.author); + let now = Utc::now(); + let mut thread = Thread { + id: new_id("thr"), + provider: "local".to_string(), + // Stamped under the lock below so it matches the snapshot list() + // and update_thread() read, even if a branch switch races us. + branch: String::new(), + path: clean.path, + side: clean.side.clone(), + line: clean.line, + end_side: String::new(), + end_line: 0, + status: "open".to_string(), + created_at: now, + updated_at: now, + comments: vec![Comment { + id: new_id("cmt"), + author, + body: clean.body, + created_at: now, + }], + reply_to_id: None, + url: String::new(), + }; + if clean.end_line != clean.line || clean.end_side != clean.side { + thread.end_side = clean.end_side; + thread.end_line = clean.end_line; + } + + let _guard = self.lock.lock().expect("comment store lock poisoned"); + thread.branch = self.branch(); + let mut file = self.load()?; + file.threads.push(thread.clone()); + self.save(file)?; + Ok(thread) + } + + pub fn add_reply(&self, thread_id: &str, input: AddReplyInput) -> Result { + let body = input.body.trim().to_string(); + if body.is_empty() { + return validation("body is required"); + } + let author = clean_author(&self.root, input.author); + self.update_thread(thread_id, |thread, now| { + thread.comments.push(Comment { + id: new_id("cmt"), + author, + body, + created_at: now, + }); + thread.updated_at = now; + }) + } + + pub fn resolve(&self, thread_id: &str) -> Result { + self.set_status(thread_id, "resolved") + } + + pub fn reopen(&self, thread_id: &str) -> Result { + self.set_status(thread_id, "open") + } + + pub fn delete(&self, thread_id: &str) -> Result<()> { + let thread_id = thread_id.trim(); + if thread_id.is_empty() { + return validation("thread id is required"); + } + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let mut file = self.load()?; + let branch = self.branch(); + let original_len = file.threads.len(); + file.threads + .retain(|thread| thread.id != thread_id || thread.branch != branch); + if file.threads.len() == original_len { + return Err(CommentError::NotFound); + } + self.save(file) + } + + fn set_status(&self, thread_id: &str, status: &str) -> Result { + self.update_thread(thread_id, |thread, now| { + thread.status = status.to_string(); + thread.updated_at = now; + }) + } + + fn update_thread( + &self, + thread_id: &str, + update: impl FnOnce(&mut Thread, DateTime), + ) -> Result { + let thread_id = thread_id.trim(); + if thread_id.is_empty() { + return validation("thread id is required"); + } + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let mut file = self.load()?; + let branch = self.branch(); + for thread in &mut file.threads { + if thread.id != thread_id || thread.branch != branch { + continue; + } + update(thread, Utc::now()); + let updated = thread.clone(); + self.save(file)?; + return Ok(updated); + } + Err(CommentError::NotFound) + } + + fn load(&self) -> Result { + let data = match fs::read_to_string(&self.path) { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(self.empty_file()), + Err(err) => return Err(err.into()), + }; + if data.trim().is_empty() { + return Ok(self.empty_file()); + } + let mut file: File = serde_json::from_str(&data)?; + if file.version == 0 { + file.version = 1; + } + if file.repo.is_empty() { + file.repo = self.root_string(); + } + Ok(file) + } + + fn save(&self, mut file: File) -> Result<()> { + file.version = 1; + file.repo = self.root_string(); + let dir = self + .path + .parent() + .ok_or_else(|| CommentError::Validation("comment path has no parent".to_string()))?; + fs::create_dir_all(dir)?; + let data = serde_json::to_string_pretty(&file)? + "\n"; + let tmp_name = format!(".comments-{}.json", new_id("tmp")); + let tmp_path = dir.join(tmp_name); + fs::write(&tmp_path, data)?; + fs::rename(&tmp_path, &self.path)?; + Ok(()) + } + + fn empty_file(&self) -> File { + File { + version: 1, + repo: self.root_string(), + threads: Vec::new(), + } + } + + fn root_string(&self) -> String { + self.root.to_string_lossy().to_string() + } +} + +#[derive(Debug)] +pub struct CleanThread { + pub path: String, + pub side: String, + pub line: u32, + pub end_side: String, + pub end_line: u32, + pub body: String, +} + +pub fn clean_thread_input(input: AddThreadInput) -> Result { + let mut path = input.path.trim().replace('\\', "/"); + let mut side = input.side.trim().to_string(); + let mut end_side = input.end_side.trim().to_string(); + let body = input.body.trim().to_string(); + if path.is_empty() { + return validation("path is required"); + } + if has_parent_path_segment(&path) { + return validation("path must be relative to the repository"); + } + path = clean_slash_path(&path)?; + if input.line < 1 { + return validation("line must be greater than zero"); + } + let end_line = if input.end_line == 0 { + input.line + } else { + input.end_line + }; + if end_line < 1 { + return validation("end line must be greater than zero"); + } + if end_line < input.line { + return validation("end line must be greater than or equal to line"); + } + if side.is_empty() { + side = DEFAULT_SIDE.to_string(); + } + if end_side.is_empty() { + end_side = side.clone(); + } + if side != "additions" && side != "deletions" { + return validation("side must be additions or deletions"); + } + if end_side != "additions" && end_side != "deletions" { + return validation("end side must be additions or deletions"); + } + if body.is_empty() { + return validation("body is required"); + } + Ok(CleanThread { + path, + side, + line: input.line, + end_side, + end_line, + body, + }) +} + +fn clean_slash_path(path: &str) -> Result { + let mut parts = Vec::new(); + for component in Path::new(path).components() { + match component { + Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), + Component::CurDir => {} + _ => return validation("path must be relative to the repository"), + } + } + if parts.is_empty() { + return validation("path is required"); + } + Ok(parts.join("/")) +} + +fn has_parent_path_segment(path: &str) -> bool { + path.split('/').any(|part| part == "..") +} + +fn clean_author(root: &Path, author: String) -> String { + let author = author.trim(); + if !author.is_empty() { + return author.to_string(); + } + git::config_string(root, "user.name").unwrap_or_else(|| DEFAULT_AUTHOR.to_string()) +} + +fn new_id(prefix: &str) -> String { + let bytes: [u8; 8] = rand::random(); + format!("{prefix}_{}", hex::encode(bytes)) +} + +fn is_zero(value: &u32) -> bool { + *value == 0 +} + +fn validation(message: &str) -> Result { + Err(CommentError::Validation(message.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_thread_input_defaults_range_end() { + let clean = clean_thread_input(AddThreadInput { + path: " src\\main.rs ".to_string(), + line: 3, + body: " hi ".to_string(), + ..Default::default() + }) + .unwrap(); + assert_eq!(clean.path, "src/main.rs"); + assert_eq!(clean.side, "additions"); + assert_eq!(clean.end_side, "additions"); + assert_eq!(clean.end_line, 3); + assert_eq!(clean.body, "hi"); + } + + #[test] + fn clean_thread_input_rejects_parent_paths() { + let err = clean_thread_input(AddThreadInput { + path: "../secret".to_string(), + line: 1, + body: "body".to_string(), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("path must be relative")); + } + + #[test] + fn clean_thread_input_rejects_invalid_table() { + let bad = [ + AddThreadInput { + path: "".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "../outside".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a/../b".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a/../../outside".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a\\..\\b".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 0, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 10, + end_line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + side: "right".into(), + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + end_side: "right".into(), + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + body: "".into(), + ..Default::default() + }, + ]; + for input in bad { + assert!( + clean_thread_input(input.clone()).is_err(), + "expected error for {input:?}" + ); + } + } + + fn run_git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + fn new_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init", "-b", "main"]); + run_git(dir.path(), &["config", "user.email", "test@example.com"]); + run_git(dir.path(), &["config", "user.name", "Test"]); + dir + } + + #[test] + fn store_lifecycle_add_reply_resolve_reopen_delete() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + + let thread = store + .add_thread(AddThreadInput { + path: "web/src/App.tsx".into(), + line: 42, + end_line: 45, + side: "additions".into(), + body: "Check this".into(), + ..Default::default() + }) + .unwrap(); + assert!(!thread.id.is_empty()); + assert_eq!(thread.provider, "local"); + assert_eq!(thread.status, "open"); + assert_eq!(thread.branch, "main"); + assert_eq!((thread.line, thread.end_line), (42, 45)); + assert_eq!( + (thread.side.as_str(), thread.end_side.as_str()), + ("additions", "additions") + ); + assert_eq!(thread.comments.len(), 1); + assert_eq!(thread.comments[0].body, "Check this"); + assert_eq!(thread.comments[0].author, "Test"); // from repo user.name + + let thread = store + .add_reply( + &thread.id, + AddReplyInput { + body: "Reply".into(), + author: "agent".into(), + }, + ) + .unwrap(); + assert_eq!(thread.comments.len(), 2); + assert_eq!(thread.comments[1].body, "Reply"); + assert_eq!(thread.comments[1].author, "agent"); + + assert_eq!(store.resolve(&thread.id).unwrap().status, "resolved"); + assert_eq!(store.reopen(&thread.id).unwrap().status, "open"); + + store.delete(&thread.id).unwrap(); + assert!(store.list().unwrap().is_empty()); + } + + #[test] + fn store_lists_current_branch_only() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "main".into(), + ..Default::default() + }) + .unwrap(); + + run_git(dir.path(), &["checkout", "-b", "feature/comments"]); + store + .add_thread(AddThreadInput { + path: "b.go".into(), + line: 1, + body: "feature".into(), + ..Default::default() + }) + .unwrap(); + + let threads = store.list().unwrap(); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].path, "b.go"); + } + + #[test] + fn store_returns_not_found_for_other_branch() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let thread = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "main".into(), + ..Default::default() + }) + .unwrap(); + + run_git(dir.path(), &["checkout", "-b", "feature/comments"]); + let err = store.resolve(&thread.id).unwrap_err(); + assert!(matches!(err, CommentError::NotFound)); + } + + #[test] + fn store_keeps_concurrent_adds() { + let dir = new_repo(); + let store = std::sync::Arc::new(Store::new(dir.path()).unwrap()); + const COUNT: usize = 20; + std::thread::scope(|scope| { + for i in 0..COUNT { + let store = store.clone(); + scope.spawn(move || { + store + .add_thread(AddThreadInput { + path: format!("file-{i:02}.go"), + line: 1, + body: "body".into(), + ..Default::default() + }) + .unwrap(); + }); + } + }); + assert_eq!(store.list().unwrap().len(), COUNT); + } + + #[test] + fn add_thread_uses_explicit_author_over_git_config() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let thread = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "b".into(), + author: " carol ".into(), + ..Default::default() + }) + .unwrap(); + assert_eq!(thread.comments[0].author, "carol"); + } + + #[test] + fn thread_timestamps_round_trip_through_disk() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let created = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "b".into(), + ..Default::default() + }) + .unwrap(); + + // Re-open the store and reload from disk: timestamps must survive the + // JSON (RFC3339) serialize/parse cycle exactly. + let reloaded = Store::new(dir.path()).unwrap().list().unwrap(); + assert_eq!(reloaded.len(), 1); + assert_eq!(reloaded[0].created_at, created.created_at); + assert_eq!(reloaded[0].updated_at, created.updated_at); + assert_eq!(reloaded[0].id, created.id); + + // And a struct -> JSON -> struct round-trip is lossless. + let json = serde_json::to_string(&created).unwrap(); + let back: Thread = serde_json::from_str(&json).unwrap(); + assert_eq!(back.created_at, created.created_at); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b7ee54c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::{fs, path::PathBuf}; + +#[derive(Debug, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub ui: UiConfig, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct UiConfig { + #[serde(default)] + pub color_scheme: String, + #[serde(default)] + pub diff_theme: String, + #[serde(default)] + pub diff_style: String, + #[serde(default)] + pub ui_font_family: String, + #[serde(default)] + pub code_font_family: String, + pub word_wrap: Option, + pub line_numbers: Option, + pub line_backgrounds: Option, +} + +pub fn default_path() -> anyhow::Result { + let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?; + Ok(PathBuf::from(home) + .join(".config") + .join("diffs") + .join("config.toml")) +} + +pub fn load_default() -> anyhow::Result { + load(default_path()?) +} + +pub fn load(path: impl Into) -> anyhow::Result { + let path = path.into(); + match fs::read_to_string(&path) { + Ok(data) => Ok(toml::from_str(&data)?), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), + Err(err) => Err(err.into()), + } +} + +pub fn normalize_ui(mut ui: UiConfig) -> UiConfig { + ui.color_scheme = ui.color_scheme.trim().to_string(); + ui.diff_theme = ui.diff_theme.trim().to_string(); + ui.diff_style = ui.diff_style.trim().to_string(); + ui.ui_font_family = ui.ui_font_family.trim().to_string(); + ui.code_font_family = ui.code_font_family.trim().to_string(); + ui +} + +pub fn is_color_scheme(value: &str) -> bool { + matches!(value, "dark" | "light" | "system") +} + +pub fn is_diff_style(value: &str) -> bool { + matches!(value, "split" | "unified") +} + +pub fn is_diff_theme(value: &str) -> bool { + matches!( + value, + "pierre" + | "github" + | "dark-plus" + | "light-plus" + | "one-dark-pro" + | "one-light" + | "monokai" + | "night-owl" + | "tokyo-night" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn load_missing_config_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let cfg = load(dir.path().join("missing.toml")).unwrap(); + assert!(cfg.ui.color_scheme.is_empty()); + assert!(cfg.ui.word_wrap.is_none()); + } + + #[test] + fn load_full_config() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all( + br#" +[ui] +color_scheme = "dark" +diff_theme = "github" +diff_style = "unified" +ui_font_family = '"Inter Variable", system-ui, sans-serif' +code_font_family = '"JetBrains Mono", ui-monospace, monospace' +word_wrap = true +line_numbers = false +line_backgrounds = true +"#, + ) + .unwrap(); + + let cfg = load(&path).unwrap(); + assert_eq!(cfg.ui.color_scheme, "dark"); + assert_eq!(cfg.ui.diff_theme, "github"); + assert_eq!(cfg.ui.diff_style, "unified"); + assert_eq!( + cfg.ui.ui_font_family, + r#""Inter Variable", system-ui, sans-serif"# + ); + assert_eq!( + cfg.ui.code_font_family, + r#""JetBrains Mono", ui-monospace, monospace"# + ); + assert_eq!(cfg.ui.word_wrap, Some(true)); + assert_eq!(cfg.ui.line_numbers, Some(false)); + assert_eq!(cfg.ui.line_backgrounds, Some(true)); + } + + #[test] + fn load_invalid_config_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[ui\n").unwrap(); + assert!(load(&path).is_err()); + } + + #[test] + fn normalize_ui_trims_strings() { + let got = normalize_ui(UiConfig { + color_scheme: " dark ".to_string(), + diff_theme: " github ".to_string(), + diff_style: " unified ".to_string(), + ui_font_family: " ui-sans-serif ".to_string(), + code_font_family: " ui-monospace ".to_string(), + ..Default::default() + }); + assert_eq!(got.color_scheme, "dark"); + assert_eq!(got.diff_theme, "github"); + assert_eq!(got.diff_style, "unified"); + assert_eq!(got.ui_font_family, "ui-sans-serif"); + assert_eq!(got.code_font_family, "ui-monospace"); + } + + #[test] + fn ui_option_validation() { + assert!(is_color_scheme("system")); + assert!(!is_color_scheme("auto")); + assert!(is_diff_theme("pierre")); + assert!(!is_diff_theme("missing")); + assert!(is_diff_style("split")); + assert!(!is_diff_style("side-by-side")); + } +} diff --git a/src/gh.rs b/src/gh.rs new file mode 100644 index 0000000..c1742a7 --- /dev/null +++ b/src/gh.rs @@ -0,0 +1,931 @@ +use crate::{comments, git}; +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; +use std::{path::Path, time::Duration}; +use tokio::process::Command; +use url::Url; + +pub const DEFAULT_GITHUB_HOST: &str = "github.com"; +pub const DEFAULT_GH_TIMEOUT: Duration = Duration::from_secs(10); +const GH_PATCH_TIMEOUT: Duration = Duration::from_secs(90); +const GH_COMMENTS_TIMEOUT: Duration = Duration::from_secs(30); +const GITHUB_DIFF_MEDIA: &str = "application/vnd.github.v3.diff"; + +#[derive(Debug, Clone)] +pub struct PrTarget { + pub path: String, + pub host: String, +} + +#[derive(Debug, Clone)] +pub struct RemoteRepo { + pub host: String, + pub owner: String, + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestInfo { + pub title: String, + pub state: String, + pub draft: bool, + pub merged: bool, + pub author: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub additions: i64, + pub deletions: i64, + pub changed_files: i64, + pub commits: i64, + pub head_ref: String, + pub head_label: String, + pub head_repo: String, + pub base_ref: String, + pub base_label: String, + pub base_repo: String, +} + +#[derive(Debug, Deserialize)] +struct PullApiResponse { + title: String, + state: String, + draft: bool, + merged: bool, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + additions: i64, + deletions: i64, + changed_files: i64, + commits: i64, + user: Option, + head: PullRef, + base: PullRef, +} + +#[derive(Debug, Deserialize)] +struct PullRef { + #[serde(rename = "ref")] + ref_name: String, + label: String, + repo: Option, + #[serde(default)] + sha: String, +} + +#[derive(Debug, Deserialize)] +struct RepoName { + full_name: String, +} + +#[derive(Debug, Deserialize)] +struct Author { + login: String, +} + +pub async fn run(dir: impl AsRef, args: &[&str]) -> anyhow::Result { + let output = tokio::time::timeout( + DEFAULT_GH_TIMEOUT, + Command::new("gh").args(args).current_dir(dir).output(), + ) + .await + .context("gh timed out")??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + bail!("gh failed"); + } + bail!("{stderr}"); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub async fn run_bytes(label: &str, args: &[String], timeout: Duration) -> anyhow::Result> { + let output = tokio::time::timeout(timeout, Command::new("gh").args(args).output()) + .await + .with_context(|| format!("{label} timed out"))??; + if output.status.success() { + return Ok(output.stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + bail!("{label} failed"); + } + bail!("{label} failed: {stderr}"); +} + +pub async fn current_branch_pr_url(dir: impl AsRef) -> anyhow::Result { + let url = run(dir, &["pr", "view", "--json", "url", "-q", ".url"]) + .await + .context("resolve PR for current branch")?; + if url.is_empty() { + bail!("no pull request found for the current branch"); + } + Ok(url) +} + +pub async fn pr_target_from_args( + args: &[String], + dir: impl AsRef, +) -> anyhow::Result { + if args.is_empty() { + let url = current_branch_pr_url(dir).await?; + return parse_pr_target(&url); + } + if let Some(number) = pr_number(args) { + let remote = git::remote_url(dir, "origin") + .with_context(|| format!("resolve current repository for PR #{number}"))?; + let repo = repo_from_remote_url(&remote) + .with_context(|| format!("resolve current repository for PR #{number}"))?; + return Ok(PrTarget { + path: format!("/{}/{}/pull/{number}", repo.owner, repo.name), + host: repo.host, + }); + } + parse_pr_target(&args[0]) +} + +pub fn parse_pr_target(target: &str) -> anyhow::Result { + let mut target = target.trim().to_string(); + if target.is_empty() { + bail!("expected one GitHub PR target"); + } + let mut host = String::new(); + let lower = target.to_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + let url = Url::parse(&target)?; + host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("target URL must include a host"))? + .to_lowercase(); + target = url.path().to_string(); + } + if !target.starts_with('/') { + target = format!("/{target}"); + } + let parts: Vec<&str> = target.trim_matches('/').split('/').collect(); + if parts.len() >= 4 + && parts[2] == "pull" + && !parts[3].is_empty() + && (parts.len() == 4 || is_pull_request_subpage(&parts[4..])) + { + return Ok(PrTarget { + path: format!("/{}/{}/pull/{}", parts[0], parts[1], parts[3]), + host, + }); + } + bail!("target must be a GitHub PR URL or /org/repo/pull/123") +} + +fn pr_number(args: &[String]) -> Option { + if args.len() != 1 { + return None; + } + let value = args[0].trim(); + value + .parse::() + .ok() + .filter(|number| *number > 0) + .map(|_| value.to_string()) +} + +pub fn repo_from_remote_url(remote: &str) -> anyhow::Result { + let remote = remote.trim(); + if remote.is_empty() { + bail!("origin remote URL is empty"); + } + let (host, path) = if remote.contains("://") { + let url = Url::parse(remote)?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("origin remote URL must include a host"))? + .to_lowercase(); + (host, url.path().to_string()) + } else { + let (user_host, path) = remote.split_once(':').ok_or_else(|| { + anyhow::anyhow!("origin remote URL must be an absolute URL or SCP-style remote") + })?; + if user_host.contains('/') { + bail!("origin remote URL must be an absolute URL or SCP-style remote"); + } + let host = user_host + .split_once('@') + .map(|(_, host)| host) + .unwrap_or(user_host) + .to_lowercase(); + (host, path.to_string()) + }; + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + if parts.len() < 2 { + bail!("origin remote URL must include owner and repository"); + } + let name = parts[1].trim_end_matches(".git"); + if parts[0].is_empty() || name.is_empty() { + bail!("origin remote URL must include owner and repository"); + } + Ok(RemoteRepo { + host, + owner: parts[0].to_string(), + name: name.to_string(), + }) +} + +fn is_pull_request_subpage(parts: &[&str]) -> bool { + matches!(parts, ["checks" | "commits" | "files" | "reviews"]) +} + +pub async fn pull_request_patch( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let args = vec![ + "api".to_string(), + format!("repos/{org}/{repo}/pulls/{number}"), + "--hostname".to_string(), + github_host.to_string(), + "-H".to_string(), + format!("Accept: {GITHUB_DIFF_MEDIA}"), + ]; + Ok(String::from_utf8( + run_bytes("gh api", &args, GH_PATCH_TIMEOUT).await?, + )?) +} + +async fn fetch_pull( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let args = vec![ + "api".to_string(), + format!("repos/{org}/{repo}/pulls/{number}"), + "--hostname".to_string(), + github_host.to_string(), + ]; + Ok(serde_json::from_slice( + &run_bytes("gh api pull request", &args, GH_COMMENTS_TIMEOUT).await?, + )?) +} + +async fn pull_request_head_sha( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let sha = fetch_pull(github_host, org, repo, number).await?.head.sha; + if sha.is_empty() { + bail!("pull request head sha is missing"); + } + Ok(sha) +} + +pub async fn pull_request_info( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let response = fetch_pull(github_host, org, repo, number).await?; + Ok(PullRequestInfo { + title: response.title, + state: response.state, + draft: response.draft, + merged: response.merged, + author: response.user.map(|user| user.login).unwrap_or_default(), + created_at: response.created_at, + updated_at: response.updated_at, + additions: response.additions, + deletions: response.deletions, + changed_files: response.changed_files, + commits: response.commits, + head_ref: response.head.ref_name, + head_label: response.head.label, + head_repo: response + .head + .repo + .map(|repo| repo.full_name) + .unwrap_or_default(), + base_ref: response.base.ref_name, + base_label: response.base.label, + base_repo: response + .base + .repo + .map(|repo| repo.full_name) + .unwrap_or_default(), + }) +} + +fn github_side(side: &str) -> &str { + match side { + "deletions" => "LEFT", + _ => "RIGHT", + } +} + +fn comment_side(side: &str) -> &str { + match side { + "RIGHT" => "additions", + "LEFT" => "deletions", + _ => "", + } +} + +// --- GitHub review-thread CRUD (ported from internal/server/github_comments.go) --- + +// GitHub's GraphQL API returns `null` for fields like `line`, `path`, and +// `endCursor` (e.g. outdated or file-level threads). Go's encoding/json maps +// null to the zero value silently; serde errors unless we coalesce it here. +fn null_to_default<'de, D, T>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Deserialize)] +struct ReviewThreadsResponse { + #[serde(default)] + data: ReviewThreadsData, +} + +#[derive(Debug, Default, Deserialize)] +struct ReviewThreadsData { + #[serde(default)] + repository: RepositoryNode, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RepositoryNode { + #[serde(default)] + pull_request: PullRequestNode, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PullRequestNode { + #[serde(default)] + review_threads: ReviewThreadsConn, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewThreadsConn { + #[serde(default)] + nodes: Vec, + #[serde(default)] + page_info: PageInfo, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + #[serde(default)] + has_next_page: bool, + #[serde(default, deserialize_with = "null_to_default")] + end_cursor: String, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewThread { + #[serde(default, deserialize_with = "null_to_default")] + id: String, + #[serde(default)] + is_resolved: bool, + #[serde(default, deserialize_with = "null_to_default")] + path: String, + #[serde(default, deserialize_with = "null_to_default")] + line: i64, + #[serde(default, deserialize_with = "null_to_default")] + diff_side: String, + #[serde(default, deserialize_with = "null_to_default")] + start_line: i64, + #[serde(default, deserialize_with = "null_to_default")] + start_diff_side: String, + #[serde(default)] + comments: ReviewCommentsConn, +} + +#[derive(Debug, Default, Deserialize)] +struct ReviewCommentsConn { + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewComment { + #[serde(default, deserialize_with = "null_to_default")] + id: String, + #[serde(default, deserialize_with = "null_to_default")] + database_id: i64, + author: Option, + #[serde(default, deserialize_with = "null_to_default")] + body: String, + #[serde(default, deserialize_with = "null_to_default")] + url: String, + created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +struct CreatedComment { + #[serde(default)] + id: i64, + #[serde(default)] + node_id: String, +} + +pub async fn list_pull_request_comments( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result> { + let mut threads = Vec::new(); + let mut cursor = String::new(); + loop { + let mut args = vec![ + "api".to_string(), + "graphql".to_string(), + "--hostname".to_string(), + github_host.to_string(), + "-f".to_string(), + format!("query={REVIEW_THREADS_QUERY}"), + "-F".to_string(), + format!("owner={org}"), + "-F".to_string(), + format!("name={repo}"), + "-F".to_string(), + format!("number={number}"), + ]; + if !cursor.is_empty() { + args.push("-F".to_string()); + args.push(format!("cursor={cursor}")); + } + let out = run_bytes("gh api graphql", &args, GH_COMMENTS_TIMEOUT).await?; + let response: ReviewThreadsResponse = serde_json::from_slice(&out)?; + let page = response.data.repository.pull_request.review_threads; + for thread in page.nodes { + if let Some(converted) = convert_github_thread(thread) { + threads.push(converted); + } + } + if !page.page_info.has_next_page || page.page_info.end_cursor.is_empty() { + return Ok(threads); + } + cursor = page.page_info.end_cursor; + } +} + +async fn find_pull_request_thread( + github_host: &str, + org: &str, + repo: &str, + number: &str, + matches: impl Fn(&comments::Thread) -> bool, +) -> anyhow::Result { + let threads = list_pull_request_comments(github_host, org, repo, number).await?; + threads + .into_iter() + .find(|thread| matches(thread)) + .ok_or_else(|| anyhow::anyhow!("comment thread not found")) +} + +pub async fn add_pull_request_comment( + github_host: &str, + org: &str, + repo: &str, + number: &str, + input: comments::AddThreadInput, +) -> anyhow::Result { + let clean = comments::clean_thread_input(input)?; + let sha = pull_request_head_sha(github_host, org, repo, number).await?; + + let mut args = vec![ + "api".to_string(), + "-X".to_string(), + "POST".to_string(), + format!("repos/{org}/{repo}/pulls/{number}/comments"), + "--hostname".to_string(), + github_host.to_string(), + "--raw-field".to_string(), + format!("body={}", clean.body), + "--raw-field".to_string(), + format!("commit_id={sha}"), + "--raw-field".to_string(), + format!("path={}", clean.path), + "--raw-field".to_string(), + format!("side={}", github_side(&clean.end_side)), + "--field".to_string(), + format!("line={}", clean.end_line), + ]; + if clean.end_line != clean.line || clean.end_side != clean.side { + args.push("--field".to_string()); + args.push(format!("start_line={}", clean.line)); + args.push("--raw-field".to_string()); + args.push(format!("start_side={}", github_side(&clean.side))); + } + let out = run_bytes( + "gh api create pull request comment", + &args, + GH_COMMENTS_TIMEOUT, + ) + .await?; + let created: CreatedComment = serde_json::from_slice(&out)?; + let created_db = created.id.to_string(); + find_pull_request_thread(github_host, org, repo, number, |thread| { + thread.comments.iter().any(|comment| { + comment.id == created.node_id || (created.id != 0 && comment.id == created_db) + }) + }) + .await +} + +pub async fn add_pull_request_reply( + github_host: &str, + org: &str, + repo: &str, + number: &str, + thread_id: &str, + input: comments::AddReplyInput, +) -> anyhow::Result { + let body = input.body.trim().to_string(); + if body.is_empty() { + bail!("body is required"); + } + let thread = + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await?; + let reply_to = thread + .reply_to_id + .filter(|id| *id != 0) + .ok_or_else(|| anyhow::anyhow!("pull request thread has no reply target"))?; + + let args = vec![ + "api".to_string(), + "-X".to_string(), + "POST".to_string(), + format!("repos/{org}/{repo}/pulls/{number}/comments/{reply_to}/replies"), + "--hostname".to_string(), + github_host.to_string(), + "--raw-field".to_string(), + format!("body={body}"), + ]; + run_bytes( + "gh api create pull request comment reply", + &args, + GH_COMMENTS_TIMEOUT, + ) + .await?; + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await +} + +pub async fn set_pull_request_thread_resolved( + github_host: &str, + org: &str, + repo: &str, + number: &str, + thread_id: &str, + resolved: bool, +) -> anyhow::Result { + let (mutation, label) = if resolved { + ( + RESOLVE_REVIEW_THREAD_MUTATION, + "gh api resolve review thread", + ) + } else { + ( + UNRESOLVE_REVIEW_THREAD_MUTATION, + "gh api unresolve review thread", + ) + }; + let args = vec![ + "api".to_string(), + "graphql".to_string(), + "--hostname".to_string(), + github_host.to_string(), + "-f".to_string(), + format!("query={mutation}"), + "-F".to_string(), + format!("threadID={thread_id}"), + ]; + run_bytes(label, &args, GH_COMMENTS_TIMEOUT).await?; + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await +} + +fn convert_github_thread(thread: ReviewThread) -> Option { + if thread.id.is_empty() || thread.comments.nodes.is_empty() { + return None; + } + let first = &thread.comments.nodes[0]; + let last = &thread.comments.nodes[thread.comments.nodes.len() - 1]; + + let mut line = thread.line; + if thread.start_line > 0 { + line = thread.start_line; + } + if thread.path.is_empty() || line < 1 { + return None; + } + + let mut side = comment_side(&thread.start_diff_side); + if side.is_empty() { + side = comment_side(&thread.diff_side); + } + if side.is_empty() { + side = comments::DEFAULT_SIDE; + } + + let mut end_line = thread.line; + if end_line == 0 { + end_line = line; + } + let mut end_side = comment_side(&thread.diff_side); + if end_side.is_empty() { + end_side = side; + } + + let status = if thread.is_resolved { + "resolved" + } else { + "open" + }; + let reply_to_id = (first.database_id != 0).then_some(first.database_id); + + let mut converted = comments::Thread { + id: thread.id, + provider: "github".to_string(), + branch: String::new(), + path: thread.path, + side: side.to_string(), + line: line as u32, + end_side: String::new(), + end_line: 0, + status: status.to_string(), + created_at: first.created_at, + updated_at: last.created_at, + comments: thread + .comments + .nodes + .iter() + .map(|comment| comments::Comment { + id: comment_id(comment), + author: comment_author(comment), + body: comment.body.clone(), + created_at: comment.created_at, + }) + .collect(), + reply_to_id, + url: first.url.clone(), + }; + if end_line != line || end_side != side { + converted.end_side = end_side.to_string(); + converted.end_line = end_line as u32; + } + Some(converted) +} + +fn comment_id(comment: &ReviewComment) -> String { + if !comment.id.is_empty() { + return comment.id.clone(); + } + if comment.database_id != 0 { + return comment.database_id.to_string(); + } + String::new() +} + +fn comment_author(comment: &ReviewComment) -> String { + match &comment.author { + Some(author) if !author.login.is_empty() => author.login.clone(), + _ => "github".to_string(), + } +} + +const REVIEW_THREADS_QUERY: &str = r#" +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + path + line + diffSide + startLine + startDiffSide + comments(first: 100) { + nodes { + id + databaseId + author { + login + } + body + url + createdAt + } + } + } + } + } + } +}"#; + +const RESOLVE_REVIEW_THREAD_MUTATION: &str = r#" +mutation($threadID: ID!) { + resolveReviewThread(input: {threadId: $threadID}) { + thread { + id + isResolved + } + } +}"#; + +const UNRESOLVE_REVIEW_THREAD_MUTATION: &str = r#" +mutation($threadID: ID!) { + unresolveReviewThread(input: {threadId: $threadID}) { + thread { + id + isResolved + } + } +}"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pr_target_paths_and_hosts() { + // (input, want_path, want_host) + let cases = [ + ("/org/repo/pull/123", "/org/repo/pull/123", ""), + ("org/repo/pull/123", "/org/repo/pull/123", ""), + ( + "https://github.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.com", + ), + ( + "https://github.example.com:8443/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://GITHUB.example.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "HTTPS://github.example.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://github.example.com/org/repo/pull/123/files", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://github.example.com/org/repo/pull/123/commits", + "/org/repo/pull/123", + "github.example.com", + ), + ]; + for (input, want_path, want_host) in cases { + let target = parse_pr_target(input).unwrap_or_else(|e| panic!("{input}: {e}")); + assert_eq!(target.path, want_path, "path for {input}"); + assert_eq!(target.host, want_host, "host for {input}"); + } + } + + #[test] + fn parse_pr_target_rejects_non_pr() { + for input in [ + "", + "org/repo", + "https://github.com/org/repo", + "/org/repo/pull/", + ] { + assert!( + parse_pr_target(input).is_err(), + "expected error for {input:?}" + ); + } + } + + #[test] + fn repo_from_remote_url_variants() { + let https = repo_from_remote_url("https://github.com/org/repo.git").unwrap(); + assert_eq!( + ( + https.host.as_str(), + https.owner.as_str(), + https.name.as_str() + ), + ("github.com", "org", "repo") + ); + + let scp = repo_from_remote_url("git@github.com:org/repo.git").unwrap(); + assert_eq!( + (scp.host.as_str(), scp.owner.as_str(), scp.name.as_str()), + ("github.com", "org", "repo") + ); + + let ssh = repo_from_remote_url("ssh://git@github.example.com/org/repo.git").unwrap(); + assert_eq!( + (ssh.host.as_str(), ssh.owner.as_str(), ssh.name.as_str()), + ("github.example.com", "org", "repo") + ); + + assert!(repo_from_remote_url("https://github.com/org").is_err()); + assert!(repo_from_remote_url("").is_err()); + } + + #[test] + fn pr_number_only_for_single_positive_integer() { + assert_eq!(pr_number(&["123".to_string()]), Some("123".to_string())); + assert_eq!(pr_number(&["0".to_string()]), None); + assert_eq!(pr_number(&["abc".to_string()]), None); + assert_eq!(pr_number(&["1".to_string(), "2".to_string()]), None); + assert_eq!(pr_number(&[]), None); + } + + #[test] + fn side_mappings_round_trip() { + assert_eq!(github_side("deletions"), "LEFT"); + assert_eq!(github_side("additions"), "RIGHT"); + assert_eq!(github_side("anything"), "RIGHT"); + assert_eq!(comment_side("LEFT"), "deletions"); + assert_eq!(comment_side("RIGHT"), "additions"); + assert_eq!(comment_side("?"), ""); + } + + #[test] + fn convert_github_thread_maps_fields_and_range() { + let created: chrono::DateTime = "2026-05-23T12:00:00Z".parse().unwrap(); + let updated: chrono::DateTime = "2026-05-23T13:00:00Z".parse().unwrap(); + let thread = ReviewThread { + id: "thr1".to_string(), + is_resolved: true, + path: "src/app.rs".to_string(), + line: 10, + diff_side: "RIGHT".to_string(), + start_line: 8, + start_diff_side: "RIGHT".to_string(), + comments: ReviewCommentsConn { + nodes: vec![ + ReviewComment { + id: "c1".to_string(), + database_id: 42, + author: Some(Author { + login: "alice".to_string(), + }), + body: "first".to_string(), + url: "http://example/c1".to_string(), + created_at: created, + }, + ReviewComment { + id: "c2".to_string(), + database_id: 43, + author: None, + body: "second".to_string(), + url: "http://example/c2".to_string(), + created_at: updated, + }, + ], + }, + }; + let converted = convert_github_thread(thread).expect("thread converts"); + assert_eq!(converted.id, "thr1"); + assert_eq!(converted.provider, "github"); + assert_eq!(converted.status, "resolved"); + assert_eq!(converted.line, 8); // start_line wins + assert_eq!(converted.end_line, 10); // thread.line + assert_eq!(converted.side, "additions"); + assert_eq!(converted.end_side, "additions"); + assert_eq!(converted.reply_to_id, Some(42)); + assert_eq!(converted.url, "http://example/c1"); + assert_eq!(converted.created_at, created); + assert_eq!(converted.updated_at, updated); + assert_eq!(converted.comments.len(), 2); + assert_eq!(converted.comments[0].author, "alice"); + assert_eq!(converted.comments[1].author, "github"); // fallback + } + + #[test] + fn convert_github_thread_skips_empty() { + assert!(convert_github_thread(ReviewThread::default()).is_none()); + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..3db537e --- /dev/null +++ b/src/git.rs @@ -0,0 +1,384 @@ +use git2::{ + BranchType, Diff, DiffFindOptions, DiffFormat, DiffOptions, ErrorCode, ObjectType, Oid, + Repository, Status, StatusOptions, +}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GitError { + #[error("not a git repository")] + NotRepository, + #[error(transparent)] + Git(#[from] git2::Error), + #[error("repository has no working tree")] + NoWorkdir, + #[error("invalid utf-8 path in repository")] + InvalidPath, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangeAction { + Added, + Modified, + Deleted, + Renamed, +} + +impl ChangeAction { + pub fn as_str(self) -> &'static str { + match self { + ChangeAction::Added => "added", + ChangeAction::Modified => "modified", + ChangeAction::Deleted => "deleted", + ChangeAction::Renamed => "renamed", + } + } +} + +/// Maps a git2 status to a `ChangeAction` using the same precedence as the Go +/// watcher's `gitStatusAction`: deletion, then rename/copy, then addition, +/// otherwise modification. +fn status_action(status: Status) -> ChangeAction { + if status.intersects(Status::INDEX_DELETED | Status::WT_DELETED) { + ChangeAction::Deleted + } else if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) { + ChangeAction::Renamed + } else if status.intersects(Status::INDEX_NEW | Status::WT_NEW) { + ChangeAction::Added + } else { + ChangeAction::Modified + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangedFile { + pub path: String, + pub action: ChangeAction, +} + +pub fn discover(cwd: impl AsRef) -> Result { + Repository::discover(cwd).map_err(|err| { + if err.code() == ErrorCode::NotFound { + GitError::NotRepository + } else { + GitError::Git(err) + } + }) +} + +pub fn root(cwd: impl AsRef) -> Result { + let repo = discover(cwd)?; + repo.workdir() + .map(Path::to_path_buf) + .ok_or(GitError::NoWorkdir) +} + +pub fn branch(cwd: impl AsRef) -> String { + discover(cwd) + .ok() + .and_then(|repo| branch_for_repo(&repo).ok()) + .unwrap_or_default() +} + +pub fn branch_for_repo(repo: &Repository) -> Result { + match repo.head() { + Ok(head) => { + if let Some(name) = head.shorthand().filter(|name| !name.is_empty()) { + return Ok(name.to_string()); + } + // Detached HEAD: fall back to the short commit oid. + let commit = head.peel_to_commit()?; + Ok(commit.id().to_string().chars().take(7).collect()) + } + // Unborn branch (fresh repo, no commits yet): `git branch --show-current` + // still reports the branch HEAD points at, so read it from the symref. + Err(err) if err.code() == ErrorCode::UnbornBranch => { + head_branch_name(repo).ok_or(GitError::Git(err)) + } + Err(err) => Err(GitError::Git(err)), + } +} + +fn head_branch_name(repo: &Repository) -> Option { + let head = repo.find_reference("HEAD").ok()?; + let target = head.symbolic_target()?; + target + .strip_prefix("refs/heads/") + .map(|name| name.to_string()) +} + +pub fn ref_exists(cwd: impl AsRef, name: &str) -> bool { + discover(cwd) + .and_then(|repo| { + repo.revparse_single(name)? + .peel(ObjectType::Commit) + .map(|_| ()) + .map_err(GitError::from) + }) + .is_ok() +} + +pub fn resolve_local_ref(cwd: impl AsRef, name: &str) -> Option { + if ref_exists(&cwd, name) { + return Some(name.to_string()); + } + let candidate = format!("origin/{name}"); + ref_exists(cwd, &candidate).then_some(candidate) +} + +pub fn remote_url(cwd: impl AsRef, remote: &str) -> Result { + let repo = discover(cwd)?; + Ok(repo + .find_remote(remote)? + .url() + .unwrap_or_default() + .to_string()) +} + +pub fn config_string(cwd: impl AsRef, key: &str) -> Option { + discover(cwd) + .ok() + .and_then(|repo| repo.config().ok()) + .and_then(|cfg| cfg.get_string(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub fn has_head(repo: &Repository) -> bool { + repo.head() + .and_then(|head| head.peel_to_commit()) + .map(|_| ()) + .is_ok() +} + +pub fn local_diff(cwd: impl AsRef) -> Result { + let repo = discover(cwd)?; + if has_head(&repo) { + // Working tree (with index) vs HEAD, with untracked files inline. + let head_tree = repo.head()?.peel_to_tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts))?) + } else { + // No HEAD yet: staged (empty tree -> index) then unstaged (index -> workdir). + let index = repo.index()?; + let mut patch = String::new(); + let mut staged_opts = diff_options(); + append_diff( + &mut patch, + repo.diff_tree_to_index(None, Some(&index), Some(&mut staged_opts))?, + )?; + let mut workdir_opts = diff_options(); + append_diff( + &mut patch, + repo.diff_index_to_workdir(Some(&index), Some(&mut workdir_opts))?, + )?; + Ok(patch) + } +} + +pub fn branch_diff(cwd: impl AsRef, base: &str, include_dirty: bool) -> Result { + let repo = discover(cwd)?; + let head = repo.head()?.peel_to_commit()?; + let base_commit = repo.revparse_single(base)?.peel_to_commit()?; + let merge_base = repo.merge_base(base_commit.id(), head.id())?; + if include_dirty { + diff_oid_to_workdir(&repo, merge_base) + } else { + diff_oid_to_tree(&repo, merge_base, head.id()) + } +} + +fn diff_oid_to_tree(repo: &Repository, from: Oid, to: Oid) -> Result { + let from_tree = repo.find_commit(from)?.tree()?; + let to_tree = repo.find_commit(to)?.tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut opts))?) +} + +fn diff_oid_to_workdir(repo: &Repository, from: Oid) -> Result { + let tree = repo.find_commit(from)?.tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))?) +} + +/// Renders a prepared diff (with rename detection) to unified-diff text. +fn render(diff: Diff<'_>) -> Result { + let mut patch = String::new(); + append_diff(&mut patch, diff)?; + Ok(patch) +} + +fn diff_options() -> DiffOptions { + let mut opts = DiffOptions::new(); + opts.include_untracked(true) + .show_untracked_content(true) + .recurse_untracked_dirs(true) + .include_typechange(true) + .include_typechange_trees(true) + .ignore_submodules(false); + opts +} + +fn append_diff(output: &mut String, mut diff: Diff<'_>) -> Result<()> { + let mut find = DiffFindOptions::new(); + find.renames(true).copies(false); + let _ = diff.find_similar(Some(&mut find)); + + diff.print(DiffFormat::Patch, |_delta, _hunk, line| { + if matches!(line.origin(), ' ' | '+' | '-' | '\\') { + output.push(line.origin()); + } + if let Ok(text) = std::str::from_utf8(line.content()) { + output.push_str(text); + } else { + output.push_str(&String::from_utf8_lossy(line.content())); + } + true + })?; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + Ok(()) +} + +fn status_options() -> StatusOptions { + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + opts +} + +fn status_entry_path(entry: &git2::StatusEntry<'_>) -> Option { + entry + .head_to_index() + .and_then(|d| d.new_file().path()) + .or_else(|| entry.index_to_workdir().and_then(|d| d.new_file().path())) + .or_else(|| entry.path().map(Path::new)) + .map(|path| path.to_string_lossy().replace('\\', "/")) +} + +pub fn changed_files(cwd: impl AsRef) -> Result> { + let repo = discover(cwd)?; + let statuses = repo.statuses(Some(&mut status_options()))?; + let mut files = Vec::new(); + for entry in statuses.iter() { + let path = status_entry_path(&entry).ok_or(GitError::InvalidPath)?; + files.push(ChangedFile { + path, + action: status_action(entry.status()), + }); + } + files.sort_by(|a, b| a.path.cmp(&b.path)); + files.dedup_by(|a, b| a.path == b.path); + Ok(files) +} + +/// Builds a map of repository-relative (forward-slash) path → change action, +/// mirroring Go's `gitStatus`. Used by the watcher to label changed files for +/// the reload logger. +pub fn status_map(repo: &Repository) -> Result> { + let statuses = repo.statuses(Some(&mut status_options()))?; + let mut map = BTreeMap::new(); + for entry in statuses.iter() { + if let Some(path) = status_entry_path(&entry) { + map.insert(path, status_action(entry.status())); + } + } + Ok(map) +} + +pub fn default_branch(cwd: impl AsRef) -> Option { + let repo = discover(cwd).ok()?; + for candidate in ["main", "master"] { + if repo.find_branch(candidate, BranchType::Local).is_ok() { + return Some(candidate.to_string()); + } + let origin = format!("origin/{candidate}"); + if ref_exists(repo.workdir()?, &origin) { + return Some(origin); + } + } + None +} + +/// Reports whether `path` is ignored by the repository's gitignore rules, using +/// libgit2's native matcher (the full hierarchy: nested `.gitignore`, +/// `.git/info/exclude`, and the global `core.excludesFile`). Paths outside the +/// working tree, or any lookup error, are treated as not ignored. +pub fn is_path_ignored(repo: &Repository, path: impl AsRef) -> bool { + let path = path.as_ref(); + let relative = repo + .workdir() + .and_then(|workdir| path.strip_prefix(workdir).ok()) + .unwrap_or(path); + repo.is_path_ignored(relative).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, process::Command}; + + fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + #[test] + fn patch_lines_include_unified_diff_origin_prefixes() { + let repo = tempfile::tempdir().unwrap(); + git(repo.path(), &["init"]); + git(repo.path(), &["config", "user.email", "diffs@example.com"]); + git(repo.path(), &["config", "user.name", "Diffs Test"]); + fs::write(repo.path().join("tracked.txt"), "one\n").unwrap(); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-m", "initial"]); + + fs::write(repo.path().join("tracked.txt"), "one\ntwo\n").unwrap(); + fs::write(repo.path().join("untracked.txt"), "new\n").unwrap(); + + let patch = local_diff(repo.path()).unwrap(); + assert!(patch.contains(" one\n"), "{patch}"); + assert!(patch.contains("+two\n"), "{patch}"); + assert!(patch.contains("+new\n"), "{patch}"); + } + + #[test] + fn is_path_ignored_honors_gitignore_hierarchy() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init"]); + // Canonicalize like server::new does, so paths share libgit2's workdir + // prefix (macOS temp dirs are /var -> /private/var symlinks otherwise). + let root = dir.path().canonicalize().unwrap(); + fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap(); + fs::create_dir_all(root.join("target")).unwrap(); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("nested/.gitignore"), "secret.txt\n").unwrap(); + fs::write(root.join("target/app"), "").unwrap(); + fs::write(root.join("debug.log"), "").unwrap(); + fs::write(root.join("nested/secret.txt"), "").unwrap(); + fs::write(root.join("src/main.rs"), "").unwrap(); + let repo = discover(&root).unwrap(); + + assert!(is_path_ignored(&repo, root.join("target/app"))); + assert!(is_path_ignored(&repo, root.join("debug.log"))); + assert!(is_path_ignored(&repo, root.join("nested/secret.txt"))); + assert!(!is_path_ignored(&repo, root.join("src/main.rs"))); + // Paths outside the working tree must not be treated as ignored. + assert!(!is_path_ignored(&repo, "/etc/hosts")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..381bea8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cli; +pub mod comments; +pub mod config; +pub mod gh; +pub mod git; +pub mod server; +pub mod webassets; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..369064b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,10 @@ +#[tokio::main] +async fn main() { + let started = std::time::Instant::now(); + if let Err(err) = diffs::cli::run(started).await { + if err.downcast_ref::().is_none() { + eprintln!("{err}"); + } + std::process::exit(1); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..bc6d955 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1038 @@ +use crate::{ + comments::{self, AddReplyInput, AddThreadInput, CommentError, Store, Thread}, + config::{self, UiConfig}, + gh, git, + webassets::Assets, +}; +use axum::{ + Json, Router, + body::Body, + extract::{Path, Query, State}, + http::{StatusCode, header}, + response::{ + IntoResponse, Response, Sse, + sse::{Event, KeepAlive}, + }, + routing::{delete, get, post}, +}; +use notify::{RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::SocketAddr, + panic::AssertUnwindSafe, + path::{Path as FsPath, PathBuf}, + sync::Arc, + time::Duration, +}; +use tokio::sync::broadcast; +use tokio_stream::{StreamExt, wrappers::BroadcastStream}; + +/// Callback invoked on each debounced reload while watching, with the files +/// that changed in that batch (empty for a git-state-only change, e.g. a branch +/// switch). Used by the CLI's reload logger. +pub type OnChange = Arc) + Send + Sync>; + +#[derive(Clone)] +pub struct ServerConfig { + pub cwd: PathBuf, + pub github_host: String, + pub ui: UiConfig, + pub watch: bool, + pub on_change: Option, +} + +#[derive(Clone)] +struct AppState { + cwd: PathBuf, + github_host: String, + ui: UiConfig, + comments: Option>, + events: broadcast::Sender<()>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigResponse { + cwd: String, + git_branch: String, + github_host: String, + #[serde(skip_serializing_if = "String::is_empty")] + color_scheme: String, + #[serde(skip_serializing_if = "String::is_empty")] + diff_theme: String, + #[serde(skip_serializing_if = "String::is_empty")] + diff_style: String, + #[serde(skip_serializing_if = "String::is_empty")] + ui_font_family: String, + #[serde(skip_serializing_if = "String::is_empty")] + code_font_family: String, + #[serde(skip_serializing_if = "Option::is_none")] + word_wrap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line_numbers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line_backgrounds: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RepoContextResponse { + #[serde(skip_serializing_if = "String::is_empty")] + repo_url: String, + #[serde(skip_serializing_if = "String::is_empty")] + pr_url: String, + #[serde(skip_serializing_if = "String::is_empty")] + branch_base: String, +} + +#[derive(Debug, Deserialize)] +struct BranchDiffQuery { + base: Option, + dirty: Option, +} + +#[derive(Debug, Deserialize)] +struct CommentTargetQuery { + org: Option, + repo: Option, + number: Option, +} + +pub struct RunningServer { + pub router: Router, + _watcher: Option, +} + +pub fn new(cfg: ServerConfig) -> anyhow::Result { + let cwd = std::fs::canonicalize(if cfg.cwd.as_os_str().is_empty() { + PathBuf::from(".") + } else { + cfg.cwd + })?; + let github_host = if cfg.github_host.trim().is_empty() { + gh::DEFAULT_GITHUB_HOST.to_string() + } else { + cfg.github_host.trim().to_string() + }; + let comments = Store::new(&cwd).ok().map(Arc::new); + let (events, _) = broadcast::channel(128); + let watcher = if cfg.watch { + Some(start_watcher( + cwd.clone(), + events.clone(), + cfg.on_change.clone(), + )?) + } else { + None + }; + let state = AppState { + cwd, + github_host, + ui: config::normalize_ui(cfg.ui), + comments, + events, + }; + let router = Router::new() + .route("/api/config", get(handle_config)) + .route("/api/events", get(handle_events)) + .route("/api/local-diff", get(handle_local_diff)) + .route("/api/branch-diff", get(handle_branch_diff)) + .route("/api/repo-context", get(handle_repo_context)) + .route( + "/api/comments", + get(handle_list_comments).post(handle_add_comment), + ) + .route("/api/comments/{thread_id}", delete(handle_delete_comment)) + .route( + "/api/comments/{thread_id}/replies", + post(handle_reply_comment), + ) + .route( + "/api/comments/{thread_id}/resolve", + post(handle_resolve_comment), + ) + .route( + "/api/comments/{thread_id}/reopen", + post(handle_reopen_comment), + ) + .route( + "/api/pull/{org}/{repo}/{number}", + get(handle_pull_request_info), + ) + .route("/api/patch/{org}/{repo}/{number}", get(handle_patch)) + .fallback(handle_static) + .with_state(state); + Ok(RunningServer { + router, + _watcher: watcher, + }) +} + +pub async fn serve(addr: SocketAddr, cfg: ServerConfig) -> anyhow::Result<()> { + let running = new(cfg)?; + let listener = tokio::net::TcpListener::bind(addr).await?; + serve_router(listener, running.router).await +} + +/// Header-read timeout for incoming connections, mirroring Go's +/// `http.Server.ReadHeaderTimeout`. Bounds the slow-loris window where a client +/// opens a connection but never finishes sending request headers. +const READ_HEADER_TIMEOUT: Duration = Duration::from_secs(5); + +/// Serves `router` over `listener` with a per-connection header-read timeout. +/// +/// `axum::serve` exposes no header-read deadline, so we drive hyper's connection +/// builder directly. The timeout is enforced before routing (a tower request +/// timeout would run too late and would also break the long-lived SSE stream). +pub async fn serve_router( + listener: tokio::net::TcpListener, + router: axum::Router, +) -> anyhow::Result<()> { + use hyper::server::conn::http1; + use hyper_util::rt::{TokioIo, TokioTimer}; + use hyper_util::service::TowerToHyperService; + + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let service = TowerToHyperService::new(router.clone()); + tokio::spawn(async move { + let mut builder = http1::Builder::new(); + // header_read_timeout needs an explicit timer (hyper has no default). + builder + .timer(TokioTimer::new()) + .header_read_timeout(READ_HEADER_TIMEOUT); + // Per-connection errors (resets, header timeouts) are expected and + // isolated to that connection, so they are intentionally dropped. + // `with_upgrades` keeps the SSE stream (and any future websockets) + // working. + let _ = builder.serve_connection(io, service).with_upgrades().await; + }); + } +} + +async fn handle_config(State(state): State) -> impl IntoResponse { + let ui = &state.ui; + Json(ConfigResponse { + cwd: state.cwd.to_string_lossy().to_string(), + git_branch: git::branch(&state.cwd), + github_host: state.github_host, + color_scheme: valid(config::is_color_scheme, &ui.color_scheme), + diff_theme: valid(config::is_diff_theme, &ui.diff_theme), + diff_style: valid(config::is_diff_style, &ui.diff_style), + ui_font_family: ui.ui_font_family.clone(), + code_font_family: ui.code_font_family.clone(), + word_wrap: ui.word_wrap, + line_numbers: ui.line_numbers, + line_backgrounds: ui.line_backgrounds, + }) +} + +async fn handle_events( + State(state): State, +) -> Sse>> { + let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|event| match event { + Ok(()) => Some(Ok(Event::default().event("diff").data("{}"))), + Err(_) => None, + }); + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(25)) + .text("ping"), + ) +} + +async fn handle_local_diff(State(state): State) -> Response { + match git::local_diff(&state.cwd) { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_branch_diff( + State(state): State, + Query(query): Query, +) -> Response { + let base = query.base.unwrap_or_default().trim().to_string(); + if base.is_empty() { + return error(StatusCode::BAD_REQUEST, "base query parameter is required"); + } + if !is_safe_ref_arg(&base) { + return error( + StatusCode::BAD_REQUEST, + format!("invalid base ref: {base:?}"), + ); + } + match git::branch_diff(&state.cwd, &base, dirty_enabled(query.dirty.as_deref())) { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_repo_context(State(state): State) -> impl IntoResponse { + // The two lookups are independent; run them concurrently so the handler's + // latency is the slower call, not the sum. + let (pr_json, repo_json) = tokio::join!( + gh::run(&state.cwd, &["pr", "view", "--json", "url,baseRefName"]), + gh::run( + &state.cwd, + &["repo", "view", "--json", "url,defaultBranchRef"], + ), + ); + let pr_json = pr_json.ok(); + let repo_json = repo_json.ok(); + let pr: serde_json::Value = pr_json + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default(); + let repo: serde_json::Value = repo_json + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default(); + let branch_base = [ + pr.pointer("/baseRefName").and_then(|v| v.as_str()), + repo.pointer("/defaultBranchRef/name") + .and_then(|v| v.as_str()), + Some("main"), + Some("master"), + ] + .into_iter() + .flatten() + .find_map(|candidate| git::resolve_local_ref(&state.cwd, candidate)) + .unwrap_or_default(); + Json(RepoContextResponse { + repo_url: repo + .pointer("/url") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + pr_url: pr + .pointer("/url") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + branch_base, + }) +} + +async fn handle_list_comments( + State(state): State, + Query(target): Query, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => { + match gh::list_pull_request_comments(&state.github_host, &pr.org, &pr.repo, &pr.number) + .await + { + Ok(threads) => { + (StatusCode::OK, Json(json!({ "threads": threads }))).into_response() + } + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } + } + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.list() { + Ok(threads) => { + (StatusCode::OK, Json(json!({ "threads": threads }))).into_response() + } + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_add_comment( + State(state): State, + Query(target): Query, + Json(input): Json, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => { + match gh::add_pull_request_comment( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + input, + ) + .await + { + Ok(thread) => (StatusCode::CREATED, Json(thread)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } + } + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.add_thread(input) { + Ok(thread) => (StatusCode::CREATED, Json(thread)).into_response(), + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_delete_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(_) => error( + StatusCode::BAD_REQUEST, + "deleting GitHub comments is not supported", + ), + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.delete(&thread_id) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_reply_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, + Json(input): Json, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => pr_thread_response( + gh::add_pull_request_reply( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + &thread_id, + input, + ) + .await, + ), + CommentScope::Local => { + write_thread_or_error(state.comments, |store| store.add_reply(&thread_id, input)) + } + } +} + +async fn handle_resolve_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + set_resolved(state, target, thread_id, true).await +} + +async fn handle_reopen_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + set_resolved(state, target, thread_id, false).await +} + +async fn set_resolved( + state: AppState, + target: CommentTargetQuery, + thread_id: String, + resolved: bool, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => pr_thread_response( + gh::set_pull_request_thread_resolved( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + &thread_id, + resolved, + ) + .await, + ), + CommentScope::Local => write_thread_or_error(state.comments, |store| { + if resolved { + store.resolve(&thread_id) + } else { + store.reopen(&thread_id) + } + }), + } +} + +async fn handle_pull_request_info( + State(state): State, + Path((org, repo, number)): Path<(String, String, String)>, +) -> Response { + if let Err(err) = validate_pr_path(&org, &repo, &number) { + return error(StatusCode::BAD_REQUEST, err); + } + match gh::pull_request_info(&state.github_host, &org, &repo, &number).await { + Ok(info) => (StatusCode::OK, Json(info)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_patch( + State(state): State, + Path((org, repo, number)): Path<(String, String, String)>, +) -> Response { + if let Err(err) = validate_pr_path(&org, &repo, &number) { + return error(StatusCode::BAD_REQUEST, err); + } + match gh::pull_request_patch(&state.github_host, &org, &repo, &number).await { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_static(uri: axum::http::Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + let (asset_path, asset) = match Assets::get(path) { + Some(asset) => (path, asset), + None => match Assets::get("index.html") { + Some(asset) => ("index.html", asset), + None => { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "index.html not found in web assets", + ); + } + }, + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_for(asset_path)) + .body(Body::from(asset.data.into_owned())) + .unwrap() +} + +fn write_thread_or_error( + store: Option>, + f: impl FnOnce(&Store) -> comments::Result, +) -> Response { + let Some(store) = store else { + return comments_unavailable(); + }; + match f(&store) { + Ok(thread) => (StatusCode::OK, Json(thread)).into_response(), + Err(err) => comment_error(err), + } +} + +fn comment_error(err: CommentError) -> Response { + match err { + CommentError::NotFound => error(StatusCode::NOT_FOUND, err), + CommentError::Validation(_) => error(StatusCode::BAD_REQUEST, err), + _ => error(StatusCode::INTERNAL_SERVER_ERROR, err), + } +} + +fn text(patch: String) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from(patch)) + .unwrap() +} + +fn error(status: StatusCode, err: impl std::fmt::Display) -> Response { + (status, Json(json!({ "error": err.to_string() }))).into_response() +} + +fn valid(check: impl Fn(&str) -> bool, value: &str) -> String { + if check(value) { + value.to_string() + } else { + String::new() + } +} + +fn dirty_enabled(value: Option<&str>) -> bool { + matches!( + value.unwrap_or_default().trim().to_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +fn is_safe_ref_arg(ref_name: &str) -> bool { + if ref_name.is_empty() + || ref_name.starts_with('-') + || ref_name.contains("..") + || ref_name.contains('~') + || ref_name.contains('^') + || ref_name == "@" + || ref_name.contains('{') + || ref_name.contains('}') + || ref_name.contains('\\') + { + return false; + } + !ref_name + .chars() + .any(|c| c <= ' ' || c == '\u{7f}' || matches!(c, ':' | '?' | '*' | '[')) +} + +struct PullTarget { + org: String, + repo: String, + number: String, +} + +enum CommentScope { + Local, + Pull(PullTarget), + Invalid, +} + +/// Mirrors Go's `commentTarget`: empty org/repo/number means local comments; +/// otherwise the trio must pass the same validators as the PR routes, or the +/// request is rejected with 400 (`Invalid`). +fn comment_scope(target: &CommentTargetQuery) -> CommentScope { + let org = target.org.as_deref().unwrap_or_default(); + let repo = target.repo.as_deref().unwrap_or_default(); + let number = target.number.as_deref().unwrap_or_default(); + if org.is_empty() && repo.is_empty() && number.is_empty() { + return CommentScope::Local; + } + if safe_path_part(org) && safe_path_part(repo) && pull_number(number) { + CommentScope::Pull(PullTarget { + org: org.to_string(), + repo: repo.to_string(), + number: number.to_string(), + }) + } else { + CommentScope::Invalid + } +} + +fn invalid_pull_path() -> Response { + error(StatusCode::BAD_REQUEST, "invalid pull request path") +} + +fn comments_unavailable() -> Response { + error( + StatusCode::SERVICE_UNAVAILABLE, + "local comments require a git repository", + ) +} + +fn pr_thread_response(result: anyhow::Result) -> Response { + match result { + Ok(thread) => (StatusCode::OK, Json(thread)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +fn validate_pr_path(org: &str, repo: &str, number: &str) -> anyhow::Result<()> { + if safe_path_part(org) && safe_path_part(repo) && pull_number(number) { + Ok(()) + } else { + anyhow::bail!("invalid pull request path") + } +} + +fn pull_number(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) && !value.starts_with('0') +} + +fn safe_path_part(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.contains("..") + && !value.contains('/') + && !value.contains('\\') + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +fn mime_for(path: &str) -> &'static str { + match FsPath::new(path).extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("map") => "application/json; charset=utf-8", + Some("txt") => "text/plain; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("ico") => "image/x-icon", + Some("webp") => "image/webp", + Some("wasm") => "application/wasm", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + _ => "application/octet-stream", + } +} + +const WATCH_DEBOUNCE: Duration = Duration::from_millis(150); + +// Git-internal files whose changes mean the repository state moved (branch +// switch, commit, stage), mirroring the Go watcher's isGitStateEvent set. +const GIT_STATE_ENTRIES: [&str; 8] = [ + "HEAD", + "index", + "index.lock", + "packed-refs", + "packed-refs.lock", + "refs", + "logs", + "COMMIT_EDITMSG", +]; + +/// One debounced batch of filesystem activity. +struct WatchTick { + /// A watched `.git` state file changed (branch switch, commit, stage). + git_state: bool, + /// Repository-relative, forward-slash paths of relevant (non-ignored) changes. + paths: Vec, +} + +fn start_watcher( + cwd: PathBuf, + events: broadcast::Sender<()>, + on_change: Option, +) -> anyhow::Result { + use std::sync::mpsc::{self, RecvTimeoutError}; + + // notify invokes the event handler on its own (non-tokio) thread, so the + // handler only classifies paths and forwards a tick over a sync channel. A + // dedicated debounce thread coalesces bursts and resolves the changed files + // 150ms after the last event, matching the Go watcher's trailing timer. + let (tx, rx) = mpsc::channel::(); + let status_cwd = cwd.clone(); + std::thread::spawn(move || { + // Repository handle for `git status` lookups; lives only on this thread. + let repo = git::discover(&status_cwd).ok(); + loop { + let mut pending: BTreeSet = BTreeSet::new(); + let mut git_state = false; + match rx.recv() { + Ok(tick) => merge_tick(&mut pending, &mut git_state, tick), + Err(_) => return, // watcher dropped + } + loop { + match rx.recv_timeout(WATCH_DEBOUNCE) { + Ok(tick) => merge_tick(&mut pending, &mut git_state, tick), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => break, + } + } + + // Resolve which of the changed paths git actually reports, then + // gate the broadcast exactly like Go's notifyChange. + let status = repo.as_ref().and_then(|repo| git::status_map(repo).ok()); + let changed = match &status { + Some(map) => changed_files_for_events(&pending, map), + None => changed_files_from_events(&pending), + }; + let broadcast = !changed.is_empty() || git_state; + if broadcast { + let _ = events.send(()); + if let Some(on_change) = &on_change { + // Isolate the callback: a panic here (e.g. println! on a + // broken stdout pipe) must not unwind and kill this thread, + // which would silently stop all future SSE broadcasts. + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| on_change(changed))); + } + } + } + }); + + // A dedicated repo handle for ignore lookups on notify's handler thread. + // git2::Repository is Send but not Sync; single-threaded access here is sound. + let repo = git::discover(&cwd).ok(); + let git_dir = repo.as_ref().map(|repo| repo.path().to_path_buf()); + // Kept for the external-git-dir watch below (the closure moves `git_dir`). + let external_git_dir = git_dir.clone().filter(|dir| !dir.starts_with(&cwd)); + let event_cwd = cwd.clone(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { + return; + }; + let mut tick = WatchTick { + git_state: false, + paths: Vec::new(), + }; + for path in &event.paths { + // Git-state files take priority (checked before the .git skip below). + if git_dir + .as_deref() + .is_some_and(|git_dir| is_git_state_file(git_dir, path)) + { + tick.git_state = true; + continue; + } + // Skip VCS/temp files and gitignored paths: they never reach the diff. + if is_structurally_ignored(path) + || repo + .as_ref() + .is_some_and(|repo| git::is_path_ignored(repo, path)) + { + continue; + } + if let Ok(rel) = path.strip_prefix(&event_cwd) { + let rel = rel.to_string_lossy().replace('\\', "/"); + if !rel.is_empty() { + tick.paths.push(rel); + } + } + } + if tick.git_state || !tick.paths.is_empty() { + let _ = tx.send(tick); + } + })?; + watcher.watch(&cwd, RecursiveMode::Recursive)?; + // For linked worktrees and submodules the git dir lives outside the working + // tree, so the recursive cwd watch never sees its HEAD/index/refs changes. + // Watch it explicitly (best-effort; only git-state refreshes depend on it). + if let Some(git_dir) = external_git_dir { + let _ = watcher.watch(&git_dir, RecursiveMode::Recursive); + } + Ok(watcher) +} + +fn merge_tick(pending: &mut BTreeSet, git_state: &mut bool, tick: WatchTick) { + if tick.git_state { + *git_state = true; + } + pending.extend(tick.paths); +} + +/// Whether `path` is a watched `.git` state file (relative to the git dir). +fn is_git_state_file(git_dir: &FsPath, path: &FsPath) -> bool { + let Ok(rel) = path.strip_prefix(git_dir) else { + return false; + }; + match rel.components().next() { + None => true, // the git dir itself + Some(std::path::Component::Normal(first)) => first + .to_str() + .is_some_and(|name| GIT_STATE_ENTRIES.contains(&name)), + _ => false, + } +} + +/// Intersects the changed event paths with git's reported status (Go's +/// `changedFilesForEvents`): an event path matches directly, or matches every +/// status entry beneath it when the event was on a directory. +fn changed_files_for_events( + events: &BTreeSet, + status: &BTreeMap, +) -> Vec { + if events.is_empty() || status.is_empty() { + return Vec::new(); + } + let mut matches: BTreeMap = BTreeMap::new(); + for event in events { + let event = event.trim_matches('/'); + if event.is_empty() { + continue; + } + if let Some(action) = status.get(event) { + matches.insert(event.to_string(), *action); + continue; + } + let prefix = format!("{event}/"); + for (path, action) in status { + if path.starts_with(&prefix) { + matches.insert(path.clone(), *action); + } + } + } + matches + .into_iter() + .map(|(path, action)| git::ChangedFile { path, action }) + .collect() +} + +/// Fallback when `git status` is unavailable: report every event path as +/// modified (Go's `changedFilesFromEvents`). +fn changed_files_from_events(events: &BTreeSet) -> Vec { + events + .iter() + .filter_map(|event| { + let event = event.trim_matches('/'); + (!event.is_empty()).then(|| git::ChangedFile { + path: event.to_string(), + action: git::ChangeAction::Modified, + }) + }) + .collect() +} + +const IGNORED_DIRS: [&str; 4] = ["node_modules", ".git", ".hg", ".svn"]; + +/// Always-ignored paths, independent of gitignore: VCS internals and the comment +/// store's atomic-write temp files (which live in a tracked `.diffs/` dir, so +/// gitignore would not catch them, and watching them would cause reload loops). +/// Matches on whole path components, so it is separator-agnostic on Windows. +fn is_structurally_ignored(path: &FsPath) -> bool { + path.components().any(|component| { + let std::path::Component::Normal(name) = component else { + return false; + }; + let Some(name) = name.to_str() else { + return false; + }; + IGNORED_DIRS.contains(&name) || (name.starts_with(".comments-") && name.ends_with(".json")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_safe_ref_arg_accepts_branch_like_refs() { + for ok in ["main", "origin/main", "feature/x", "release-1.2"] { + assert!(is_safe_ref_arg(ok), "{ok} should be safe"); + } + for bad in [ + "", "-flag", "a..b", "HEAD~1", "x^", "@", "a{b", "a}b", "a\\b", "a b", "a:b", "a?b", + "a*b", "a[b", + ] { + assert!(!is_safe_ref_arg(bad), "{bad:?} should be rejected"); + } + } + + #[test] + fn pr_path_validators() { + assert!(pull_number("123")); + assert!(!pull_number("0")); + assert!(!pull_number("012")); + assert!(!pull_number("")); + assert!(!pull_number("12a")); + + assert!(safe_path_part("org.repo-1_x")); + assert!(!safe_path_part("")); + assert!(!safe_path_part("-x")); + assert!(!safe_path_part("a/b")); + assert!(!safe_path_part("a..b")); + assert!(!safe_path_part("a b")); + } + + #[test] + fn comment_scope_classification() { + let local = CommentTargetQuery { + org: None, + repo: None, + number: None, + }; + assert!(matches!(comment_scope(&local), CommentScope::Local)); + + let pull = CommentTargetQuery { + org: Some("org".into()), + repo: Some("repo".into()), + number: Some("123".into()), + }; + match comment_scope(&pull) { + CommentScope::Pull(t) => assert_eq!( + (t.org, t.repo, t.number), + ("org".into(), "repo".into(), "123".into()) + ), + _ => panic!("expected pull scope"), + } + + let invalid = CommentTargetQuery { + org: Some("../bad".into()), + repo: Some("repo".into()), + number: Some("123".into()), + }; + assert!(matches!(comment_scope(&invalid), CommentScope::Invalid)); + } + + #[test] + fn git_state_file_detection() { + let git_dir = FsPath::new("/repo/.git"); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git/HEAD"))); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git/index"))); + assert!(is_git_state_file( + git_dir, + FsPath::new("/repo/.git/refs/heads/main") + )); + assert!(is_git_state_file( + git_dir, + FsPath::new("/repo/.git/logs/HEAD") + )); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git"))); + assert!(!is_git_state_file( + git_dir, + FsPath::new("/repo/.git/objects/ab/cd") + )); + assert!(!is_git_state_file( + git_dir, + FsPath::new("/repo/src/main.rs") + )); + } + + #[test] + fn structural_ignore_matches_components() { + assert!(is_structurally_ignored(FsPath::new( + "/repo/node_modules/x/y.js" + ))); + assert!(is_structurally_ignored(FsPath::new("/repo/.git/HEAD"))); + assert!(is_structurally_ignored(FsPath::new( + "/repo/.diffs/.comments-ab12.json" + ))); + assert!(!is_structurally_ignored(FsPath::new( + "/repo/src/.gitignore" + ))); + assert!(!is_structurally_ignored(FsPath::new("/repo/my.git/x"))); + } + + #[test] + fn changed_files_for_events_intersects_status() { + let mut status = BTreeMap::new(); + status.insert("src/a.rs".to_string(), git::ChangeAction::Modified); + status.insert("src/b.rs".to_string(), git::ChangeAction::Added); + status.insert("docs/c.md".to_string(), git::ChangeAction::Deleted); + + // Direct hit + directory-prefix expansion; "missing" is dropped. + let events: BTreeSet = ["src/a.rs", "docs", "missing"] + .iter() + .map(|s| s.to_string()) + .collect(); + let changed = changed_files_for_events(&events, &status); + let got: Vec<(&str, git::ChangeAction)> = changed + .iter() + .map(|c| (c.path.as_str(), c.action)) + .collect(); + assert_eq!( + got, + vec![ + ("docs/c.md", git::ChangeAction::Deleted), + ("src/a.rs", git::ChangeAction::Modified), + ] + ); + } + + #[test] + fn changed_files_from_events_marks_modified() { + let events: BTreeSet = ["b.rs", "a.rs"].iter().map(|s| s.to_string()).collect(); + let changed = changed_files_from_events(&events); + assert_eq!(changed.len(), 2); + assert_eq!(changed[0].path, "a.rs"); // sorted + assert!( + changed + .iter() + .all(|c| c.action == git::ChangeAction::Modified) + ); + } +} diff --git a/src/webassets.rs b/src/webassets.rs new file mode 100644 index 0000000..f6a7ddb --- /dev/null +++ b/src/webassets.rs @@ -0,0 +1,5 @@ +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "web/dist"] +pub struct Assets; diff --git a/tests/diff_parity.rs b/tests/diff_parity.rs new file mode 100644 index 0000000..6af7ced --- /dev/null +++ b/tests/diff_parity.rs @@ -0,0 +1,283 @@ +//! Diff byte-parity gate (MIGRATION §4). +//! +//! The frontend's `@pierre/diffs` parser consumes git's exact unified-diff text, +//! so the libgit2-backed `local_diff`/`branch_diff` must reproduce it. The oracle +//! here is **git itself** (not the Go binary) — git is the ground truth the parser +//! targets, and this keeps the harness valid after the Go sources are removed. +//! +//! Comparison is per-file: libgit2 emits every file (including untracked) in one +//! path-sorted pass, whereas git's `diff HEAD` + per-file `--no-index` untracked +//! pipeline appends untracked last, so the global byte order differs legitimately. +//! The parser handles each `diff --git` block independently, so per-file byte +//! parity is the meaningful contract. + +use std::collections::BTreeMap; +use std::path::Path; +use std::{fs, process::Command}; + +fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); +} + +fn git_output(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("run git"); + assert!(output.status.success(), "git {args:?} failed"); + String::from_utf8(output.stdout).expect("git output is utf8") +} + +fn init_repo(dir: &Path) { + git(dir, &["init"]); + git(dir, &["config", "user.email", "diffs@example.com"]); + git(dir, &["config", "user.name", "Diffs Test"]); + // Pin diff rendering so the oracle is deterministic across host git configs. + git(dir, &["config", "core.autocrlf", "false"]); + git(dir, &["config", "diff.renames", "true"]); +} + +/// Appends a `git diff --no-index` block for every untracked file, mirroring the +/// server's `untrackedPatch`. `--no-index` exits 1 when files differ. +fn append_untracked(dir: &Path, patch: &mut String) { + let untracked = git_output(dir, &["ls-files", "--others", "--exclude-standard", "-z"]); + for name in untracked.split('\0').filter(|name| !name.is_empty()) { + let out = Command::new("git") + .args([ + "diff", + "--no-ext-diff", + "--patch", + "--no-index", + "--", + "/dev/null", + name, + ]) + .current_dir(dir) + .output() + .expect("run git no-index"); + assert_eq!(out.status.code(), Some(1), "git diff --no-index for {name}"); + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&String::from_utf8(out.stdout).expect("no-index output is utf8")); + } +} + +/// Reconstructs the server's `localDiff` reference (HEAD present). +fn git_local_patch(dir: &Path) -> String { + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + "HEAD", + "--", + ], + ); + append_untracked(dir, &mut patch); + patch +} + +/// Reconstructs the server's `localDiff` reference for a repo with no HEAD: +/// staged (`--cached`) then unstaged then untracked. +fn git_local_patch_no_head(dir: &Path) -> String { + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + "--cached", + "--", + ], + ); + let unstaged = git_output( + dir, + &["diff", "--no-ext-diff", "--patch", "--submodule=diff", "--"], + ); + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&unstaged); + append_untracked(dir, &mut patch); + patch +} + +/// Reconstructs `branchDiff` (three-dot) reference. +fn git_branch_patch(dir: &Path, base: &str) -> String { + git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + &format!("{base}...HEAD"), + "--", + ], + ) +} + +/// Reconstructs `branchDiffWithDirty` reference: merge-base to working tree, +/// plus untracked files. +fn git_branch_dirty_patch(dir: &Path, base: &str) -> String { + let merge_base = git_output(dir, &["merge-base", base, "HEAD"]) + .trim() + .to_string(); + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + &merge_base, + "--", + ], + ); + append_untracked(dir, &mut patch); + patch +} + +/// Splits a unified diff into per-file blocks keyed by the `diff --git` header. +fn split_files(patch: &str) -> BTreeMap { + let mut blocks = BTreeMap::new(); + let mut key: Option = None; + let mut buf = String::new(); + for line in patch.split_inclusive('\n') { + if line.starts_with("diff --git ") { + if let Some(key) = key.take() { + blocks.insert(key, std::mem::take(&mut buf)); + } + key = Some(line.trim_end().to_string()); + } + buf.push_str(line); + } + if let Some(key) = key.take() { + blocks.insert(key, buf); + } + blocks +} + +fn assert_per_file_parity(rust: &str, git: &str) { + assert_eq!( + split_files(rust), + split_files(git), + "per-file diff text must match git byte-for-byte" + ); +} + +#[test] +fn local_diff_matches_git_for_rich_fixture() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("modified.txt"), "before\n").unwrap(); + fs::write(dir.join("deleted.txt"), "bye\n").unwrap(); + fs::write(dir.join("renamed.txt"), "rename me unchanged\n").unwrap(); + fs::write(dir.join("nonewline.txt"), "first\nsecond\n").unwrap(); + fs::write(dir.join("crlf.txt"), "a\r\nb\r\n").unwrap(); + fs::write(dir.join("binary.bin"), [0u8, 1, 2, 3, 0, 255, 10]).unwrap(); + fs::write(dir.join("mode.sh"), "echo hi\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + + // Worktree + index changes covering the §4 divergence points. + fs::write(dir.join("modified.txt"), "before\nafter\n").unwrap(); + fs::remove_file(dir.join("deleted.txt")).unwrap(); + git(dir, &["mv", "renamed.txt", "renamed-new.txt"]); // staged rename + fs::write(dir.join("nonewline.txt"), "first\nsecond").unwrap(); // drop trailing newline + fs::write(dir.join("crlf.txt"), "a\r\nb\r\nc\r\n").unwrap(); + fs::write(dir.join("binary.bin"), [0u8, 9, 9, 9, 0, 1, 10]).unwrap(); + fs::write(dir.join("untracked.txt"), "new\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir.join("mode.sh"), fs::Permissions::from_mode(0o755)).unwrap(); + } + + let rust_patch = diffs::git::local_diff(dir).unwrap(); + let git_patch = git_local_patch(dir); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn local_diff_matches_git_for_empty_repo() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + // No commit -> no HEAD. Mix of staged, unstaged-modified, and untracked. + fs::write(dir.join("staged.txt"), "staged\n").unwrap(); + git(dir, &["add", "staged.txt"]); + fs::write(dir.join("staged.txt"), "staged\nedited\n").unwrap(); // staged + further unstaged edit + fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + + let rust_patch = diffs::git::local_diff(dir).unwrap(); + let git_patch = git_local_patch_no_head(dir); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn branch_diff_three_dot_matches_git() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("base.txt"), "base\n").unwrap(); + fs::write(dir.join("shared.txt"), "v1\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + git(dir, &["branch", "-M", "main"]); // deterministic base name across git versions + + // Diverge main with a commit the feature branch should not see (three-dot). + git(dir, &["checkout", "-q", "-b", "feature"]); + fs::write(dir.join("feature.txt"), "added on feature\n").unwrap(); + fs::write(dir.join("shared.txt"), "v2\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "feature work"]); + + git(dir, &["checkout", "-q", "main"]); + fs::write(dir.join("base.txt"), "base changed on main\n").unwrap(); + git(dir, &["commit", "-am", "main moves on"]); + git(dir, &["checkout", "-q", "feature"]); + + let rust_patch = diffs::git::branch_diff(dir, "main", false).unwrap(); + let git_patch = git_branch_patch(dir, "main"); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn branch_diff_include_dirty_matches_git() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("base.txt"), "base\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + git(dir, &["branch", "-M", "main"]); // deterministic base name across git versions + + git(dir, &["checkout", "-q", "-b", "feature"]); + fs::write(dir.join("committed.txt"), "committed on feature\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "feature commit"]); + + // Dirty working tree on top of the committed branch work. + fs::write(dir.join("base.txt"), "base dirty edit\n").unwrap(); + fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + + let rust_patch = diffs::git::branch_diff(dir, "main", true).unwrap(); + let git_patch = git_branch_dirty_patch(dir, "main"); + assert_per_file_parity(&rust_patch, &git_patch); +} From bc66628c6080730c9f20711d4cced5cc0598c7b3 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 16:58:01 +0100 Subject: [PATCH 02/14] chore: remove Go implementation Delete the Go sources (cmd/, internal/), go.mod/go.sum, the GoReleaser config, the webassets embed package, and the web/go.mod module marker now that the Rust binary is the implementation. --- .goreleaser.yaml | 79 -- cmd/diffs/branch.go | 95 --- cmd/diffs/browser.go | 34 - cmd/diffs/comments.go | 216 ------ cmd/diffs/gh.go | 51 -- cmd/diffs/git.go | 87 --- cmd/diffs/json.go | 12 - cmd/diffs/main.go | 39 - cmd/diffs/main_test.go | 937 ----------------------- cmd/diffs/output.go | 182 ----- cmd/diffs/root.go | 94 --- cmd/diffs/serve.go | 97 --- cmd/diffs/target.go | 217 ------ cmd/diffs/version.go | 21 - go.mod | 224 ------ go.sum | 977 ----------------------- internal/appconfig/config.go | 120 --- internal/appconfig/config_test.go | 95 --- internal/comments/store.go | 402 ---------- internal/comments/store_test.go | 230 ------ internal/git/git.go | 57 -- internal/server/github_comments.go | 504 ------------ internal/server/server.go | 838 -------------------- internal/server/server_test.go | 1149 ---------------------------- internal/server/watch.go | 416 ---------- internal/webassets/dist/.gitkeep | 0 internal/webassets/embed.go | 13 - web/go.mod | 4 - 28 files changed, 7190 deletions(-) delete mode 100644 .goreleaser.yaml delete mode 100644 cmd/diffs/branch.go delete mode 100644 cmd/diffs/browser.go delete mode 100644 cmd/diffs/comments.go delete mode 100644 cmd/diffs/gh.go delete mode 100644 cmd/diffs/git.go delete mode 100644 cmd/diffs/json.go delete mode 100644 cmd/diffs/main.go delete mode 100644 cmd/diffs/main_test.go delete mode 100644 cmd/diffs/output.go delete mode 100644 cmd/diffs/root.go delete mode 100644 cmd/diffs/serve.go delete mode 100644 cmd/diffs/target.go delete mode 100644 cmd/diffs/version.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/appconfig/config.go delete mode 100644 internal/appconfig/config_test.go delete mode 100644 internal/comments/store.go delete mode 100644 internal/comments/store_test.go delete mode 100644 internal/git/git.go delete mode 100644 internal/server/github_comments.go delete mode 100644 internal/server/server.go delete mode 100644 internal/server/server_test.go delete mode 100644 internal/server/watch.go delete mode 100644 internal/webassets/dist/.gitkeep delete mode 100644 internal/webassets/embed.go delete mode 100644 web/go.mod diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index ca48938..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,79 +0,0 @@ -version: 2 - -project_name: diffs - -before: - hooks: - - pnpm install --frozen-lockfile - - pnpm --dir web build - - rm -rf internal/webassets/dist - - mkdir -p internal/webassets/dist - - cp -R web/dist/. internal/webassets/dist - - touch internal/webassets/dist/.gitkeep - -builds: - - id: diffs - main: ./cmd/diffs - binary: diffs - env: - - CGO_ENABLED=0 - goos: - - darwin - - linux - - windows - goarch: - - amd64 - - arm64 - -archives: - - id: diffs - ids: - - diffs - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - -checksum: - name_template: checksums.txt - -release: - github: - owner: imfing - name: diffs-cli - -homebrew_casks: - - name: diffs - ids: - - diffs - binaries: - - diffs - repository: - owner: imfing - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - directory: Casks - url: - verified: github.com/imfing/diffs-cli/ - homepage: "https://github.com/imfing/diffs-cli" - description: "Fast, beautiful diffs on the Go" - hooks: - post: - install: | - if OS.mac? - system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/diffs"] - end - -snapshot: - version_template: "{{ incpatch .Version }}-next" - -changelog: - use: github - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" diff --git a/cmd/diffs/branch.go b/cmd/diffs/branch.go deleted file mode 100644 index 59acd53..0000000 --- a/cmd/diffs/branch.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net/url" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" -) - -func newBranchCommand(opts *cliOptions, started time.Time) *cobra.Command { - var includeDirty bool - cmd := &cobra.Command{ - Use: "branch [base]", - Short: "Review commits on the current branch against a base", - Long: "Compare HEAD against a base ref (three-dot). With no argument, infers the base from the branch's PR, repo default, or main/master.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - displayCWD, err := filepath.Abs(opts.dir) - if err != nil { - return err - } - if _, err := gitRoot(displayCWD); err != nil { - errOut := cmd.ErrOrStderr() - printLocalGitHelp(errOut, displayCWD, colorEnabled(errOut)) - _ = cmd.Help() - return quietError{err: err} - } - base, err := resolveBranchBase(args, opts.dir) - if err != nil { - return err - } - target := branchTarget(base, includeDirty) - return runServerTarget(cmd, opts, target, started) - }, - } - addServeFlags(cmd, opts, false) - cmd.Flags().BoolVar(&includeDirty, "include-dirty", false, "include staged, unstaged, and untracked changes") - return cmd -} - -func branchTarget(base string, includeDirty bool) string { - values := url.Values{} - values.Set("base", base) - if includeDirty { - values.Set("dirty", "1") - } - return "/branch?" + values.Encode() -} - -func resolveBranchBase(args []string, dir string) (string, error) { - if len(args) == 1 { - base := strings.TrimSpace(args[0]) - if base == "" { - return "", errors.New("base ref must not be empty") - } - return base, nil - } - ctx, cancel := context.WithTimeout(context.Background(), defaultGHTimeout) - defer cancel() - - if base, err := runGHPRBaseRef(ctx, dir); err == nil && base != "" { - if ref, ok := resolveLocalRef(dir, base); ok { - return ref, nil - } - } - if base, err := runGHRepoDefaultBranch(ctx, dir); err == nil && base != "" { - if ref, ok := resolveLocalRef(dir, base); ok { - return ref, nil - } - } - for _, candidate := range []string{"main", "master"} { - if ref, ok := resolveLocalRef(dir, candidate); ok { - return ref, nil - } - } - return "", fmt.Errorf("could not infer base ref; pass one explicitly, e.g. `diffs branch main`") -} - -// resolveLocalRef returns ref if it resolves to a commit locally, otherwise -// tries origin/. Inferred bases (PR base, default branch) may name -// branches that exist only as a remote-tracking ref in fresh clones. -func resolveLocalRef(dir, ref string) (string, bool) { - if gitRefExists(dir, ref) { - return ref, true - } - if candidate := "origin/" + ref; gitRefExists(dir, candidate) { - return candidate, true - } - return "", false -} diff --git a/cmd/diffs/browser.go b/cmd/diffs/browser.go deleted file mode 100644 index dc197a6..0000000 --- a/cmd/diffs/browser.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os/exec" - "runtime" - "strings" - "time" -) - -func openBrowser(url string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.CommandContext(ctx, "open", url) - case "windows": - cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url) - default: - cmd = exec.CommandContext(ctx, "xdg-open", url) - } - out, err := cmd.CombinedOutput() - if err != nil { - msg := strings.TrimSpace(string(out)) - if msg != "" { - return fmt.Errorf("%w: %s", err, msg) - } - return err - } - return nil -} diff --git a/cmd/diffs/comments.go b/cmd/diffs/comments.go deleted file mode 100644 index 6618e5a..0000000 --- a/cmd/diffs/comments.go +++ /dev/null @@ -1,216 +0,0 @@ -package main - -import ( - "fmt" - "io" - "strings" - "text/tabwriter" - - "github.com/imfing/diffs-cli/internal/comments" - "github.com/spf13/cobra" -) - -const ( - commentPreviewLimit = 72 - commentPreviewBodyLimit = commentPreviewLimit - len("...") -) - -type commentsOptions struct { - json bool -} - -func newCommentsCommand(opts *cliOptions) *cobra.Command { - commentOpts := &commentsOptions{} - cmd := &cobra.Command{ - Use: "comments", - Short: "Manage local review comments", - } - cmd.PersistentFlags().BoolVar(&commentOpts.json, "json", false, "write JSON output") - cmd.AddCommand( - newCommentsListCommand(opts, commentOpts), - newCommentsAddCommand(opts, commentOpts), - newCommentsReplyCommand(opts, commentOpts), - newCommentsResolveCommand(opts, commentOpts), - newCommentsReopenCommand(opts, commentOpts), - ) - return cmd -} - -func newCommentsListCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List local comment threads for the current branch", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - threads, err := store.List(cmd.Context()) - if err != nil { - return err - } - if commentOpts.json { - return writeJSONCLI(cmd.OutOrStdout(), map[string]any{"threads": threads}) - } - printThreads(cmd.OutOrStdout(), threads) - return nil - }) - }, - } -} - -func newCommentsAddCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - var input comments.AddThreadInput - cmd := &cobra.Command{ - Use: "add --file PATH --line LINE [--end-line LINE] --body BODY", - Short: "Create a local comment thread", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - body, err := bodyFromFlag(cmd, input.Body) - if err != nil { - return err - } - input.Body = body - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.AddThread(cmd.Context(), input) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } - cmd.Flags().StringVar(&input.Path, "file", "", "repository-relative file path") - cmd.Flags().IntVar(&input.Line, "line", 0, "line number") - cmd.Flags().StringVar(&input.Side, "side", comments.DefaultSide, "diff side: additions or deletions") - cmd.Flags().IntVar(&input.EndLine, "end-line", 0, "end line number for a multi-line comment") - cmd.Flags().StringVar(&input.EndSide, "end-side", "", "end diff side for a multi-line comment: additions or deletions") - cmd.Flags().StringVar(&input.Body, "body", "", "comment body, or - to read stdin") - cmd.Flags().StringVar(&input.Author, "author", "", "comment author") - _ = cmd.MarkFlagRequired("file") - _ = cmd.MarkFlagRequired("line") - _ = cmd.MarkFlagRequired("body") - return cmd -} - -func newCommentsReplyCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - var input comments.AddReplyInput - cmd := &cobra.Command{ - Use: "reply THREAD_ID --body BODY", - Short: "Reply to a local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - body, err := bodyFromFlag(cmd, input.Body) - if err != nil { - return err - } - input.Body = body - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.AddReply(cmd.Context(), args[0], input) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } - cmd.Flags().StringVar(&input.Body, "body", "", "reply body, or - to read stdin") - cmd.Flags().StringVar(&input.Author, "author", "", "reply author") - _ = cmd.MarkFlagRequired("body") - return cmd -} - -func newCommentsResolveCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "resolve THREAD_ID", - Short: "Resolve a local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.Resolve(cmd.Context(), args[0]) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } -} - -func newCommentsReopenCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "reopen THREAD_ID", - Short: "Reopen a resolved local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.Reopen(cmd.Context(), args[0]) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } -} - -func withCommentStore(opts *cliOptions, fn func(*comments.Store) error) error { - store, err := comments.NewStore(opts.dir) - if err != nil { - return err - } - return fn(store) -} - -func bodyFromFlag(cmd *cobra.Command, body string) (string, error) { - if body != "-" { - return body, nil - } - data, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return "", err - } - return string(data), nil -} - -func printThreadResult(w io.Writer, thread comments.Thread, asJSON bool) error { - if asJSON { - return writeJSONCLI(w, thread) - } - _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", thread.ID, thread.Status, threadLocation(thread), latestCommentBody(thread)) - return err -} - -func printThreads(w io.Writer, threads []comments.Thread) { - if len(threads) == 0 { - _, _ = fmt.Fprintln(w, "No local comment threads.") - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(tw, "ID\tSTATUS\tLOCATION\tCOMMENTS\tLATEST") - for _, thread := range threads { - _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\n", thread.ID, thread.Status, threadLocation(thread), len(thread.Comments), latestCommentBody(thread)) - } - _ = tw.Flush() -} - -func threadLocation(thread comments.Thread) string { - endLine := thread.EndLine - if endLine == 0 { - endLine = thread.Line - } - if endLine == thread.Line { - return fmt.Sprintf("%s:%d", thread.Path, thread.Line) - } - return fmt.Sprintf("%s:%d-%d", thread.Path, thread.Line, endLine) -} - -func latestCommentBody(thread comments.Thread) string { - if len(thread.Comments) == 0 { - return "" - } - body := strings.ReplaceAll(thread.Comments[len(thread.Comments)-1].Body, "\n", " ") - runes := []rune(body) - if len(runes) > commentPreviewLimit { - return string(runes[:commentPreviewBodyLimit]) + "..." - } - return body -} diff --git a/cmd/diffs/gh.go b/cmd/diffs/gh.go deleted file mode 100644 index 4f9a261..0000000 --- a/cmd/diffs/gh.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os/exec" - "strings" - "time" -) - -const defaultGHTimeout = 10 * time.Second - -var runGHPRView = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "pr", "view", "--json", "url", "-q", ".url") -} - -var runGHPRBaseRef = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "pr", "view", "--json", "baseRefName", "-q", ".baseRefName") -} - -var runGHRepoDefaultBranch = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name") -} - -func runGH(ctx context.Context, dir string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "gh", args...) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return "", errors.New(strings.TrimSpace(string(exitErr.Stderr))) - } - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -func currentBranchPRURL(dir string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), defaultGHTimeout) - defer cancel() - url, err := runGHPRView(ctx, dir) - if err != nil { - return "", fmt.Errorf("resolve PR for current branch: %w\nhint: open a PR for this branch, or pass `diffs pr `", err) - } - if url == "" { - return "", errors.New("no pull request found for the current branch") - } - return url, nil -} diff --git a/cmd/diffs/git.go b/cmd/diffs/git.go deleted file mode 100644 index 7d039f8..0000000 --- a/cmd/diffs/git.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net/url" - "strings" - - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -var errNotGitRepository = errors.New("not a git repository") - -func targetLabel(targetPath, cwd string) string { - if targetPath == "/local" { - if branch := gitBranch(cwd); branch != "" { - return branch - } - return "local repository" - } - if strings.HasPrefix(targetPath, "/branch") { - base := branchBaseFromTargetPath(targetPath) - head := gitBranch(cwd) - if head == "" { - head = "HEAD" - } - if base == "" { - return fmt.Sprintf("%s branch diff", head) - } - return fmt.Sprintf("%s -> %s", head, base) - } - parts := strings.Split(strings.Trim(targetPath, "/"), "/") - if len(parts) == 4 && parts[2] == "pull" { - return fmt.Sprintf("GitHub PR %s/%s#%s", parts[0], parts[1], parts[3]) - } - return targetPath -} - -func branchBaseFromTargetPath(targetPath string) string { - queryStart := strings.IndexByte(targetPath, '?') - if queryStart < 0 { - return "" - } - values, err := url.ParseQuery(targetPath[queryStart+1:]) - if err != nil { - return "" - } - return values.Get("base") -} - -func gitRoot(cwd string) (string, error) { - ctx, cancel := gitCtx() - defer cancel() - - root, err := gitcmd.Root(ctx, cwd) - if err != nil { - return "", fmt.Errorf("%w: %s", errNotGitRepository, cwd) - } - return root, nil -} - -func gitBranch(cwd string) string { - ctx, cancel := gitCtx() - defer cancel() - return gitcmd.Branch(ctx, cwd) -} - -func gitRefExists(cwd, ref string) bool { - ctx, cancel := gitCtx() - defer cancel() - return gitcmd.OK(ctx, cwd, "rev-parse", "--verify", "--quiet", ref+"^{commit}") -} - -func gitRemoteURL(cwd, name string) (string, error) { - ctx, cancel := gitCtx() - defer cancel() - out, err := gitcmd.Run(ctx, cwd, "remote", "get-url", name) - if err != nil { - return "", fmt.Errorf("get git remote %q URL: %w", name, err) - } - return strings.TrimSpace(string(out)), nil -} - -func gitCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) -} diff --git a/cmd/diffs/json.go b/cmd/diffs/json.go deleted file mode 100644 index 3615a46..0000000 --- a/cmd/diffs/json.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "encoding/json" - "io" -) - -func writeJSONCLI(w io.Writer, v any) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/cmd/diffs/main.go b/cmd/diffs/main.go deleted file mode 100644 index 0ef2210..0000000 --- a/cmd/diffs/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/spf13/cobra" -) - -func main() { - if err := executeRootCommand(newRootCommand(time.Now())); err != nil { - var quiet quietError - if !errors.As(err, &quiet) { - _, _ = fmt.Fprintln(os.Stderr, err) - } - os.Exit(1) - } -} - -func executeRootCommand(cmd *cobra.Command) error { - err := cmd.Execute() - if err == nil || !isUnknownCommandError(err) { - return err - } - - errOut := cmd.ErrOrStderr() - _, _ = fmt.Fprintln(errOut, err) - _, _ = fmt.Fprintln(errOut) - cmd.SetOut(errOut) - _ = cmd.Help() - return quietError{err: err} -} - -func isUnknownCommandError(err error) bool { - return strings.HasPrefix(err.Error(), "unknown command ") -} diff --git a/cmd/diffs/main_test.go b/cmd/diffs/main_test.go deleted file mode 100644 index e051a46..0000000 --- a/cmd/diffs/main_test.go +++ /dev/null @@ -1,937 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - "unicode/utf8" - - "github.com/imfing/diffs-cli/internal/comments" - "github.com/imfing/diffs-cli/internal/server" -) - -func TestMain(m *testing.M) { - for k, v := range map[string]string{ - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@example.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@example.com", - } { - _ = os.Setenv(k, v) - } - os.Exit(m.Run()) -} - -func TestPRTargetFromArgsPath(t *testing.T) { - tests := []struct { - name string - args []string - want string - }{ - {name: "path", args: []string{"/org/repo/pull/123"}, want: "/org/repo/pull/123"}, - {name: "path without leading slash", args: []string{"org/repo/pull/123"}, want: "/org/repo/pull/123"}, - {name: "url", args: []string{"https://github.com/org/repo/pull/123"}, want: "/org/repo/pull/123"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := prTargetFromArgs(tt.args) - if err != nil { - t.Fatalf("prTargetFromArgs() error = %v", err) - } - if got.Path != tt.want { - t.Fatalf("prTargetFromArgs().Path = %q, want %q", got.Path, tt.want) - } - }) - } -} - -func TestPRTargetFromArgsIncludesURLHost(t *testing.T) { - tests := []struct { - name string - args []string - wantPath string - wantHost string - }{ - {name: "path", args: []string{"/org/repo/pull/123"}, wantPath: "/org/repo/pull/123"}, - {name: "path without leading slash", args: []string{"org/repo/pull/123"}, wantPath: "/org/repo/pull/123"}, - {name: "github url", args: []string{"https://github.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.com"}, - {name: "enterprise url", args: []string{"https://github.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "enterprise url with port", args: []string{"https://github.example.com:8443/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "mixed case host", args: []string{"https://GITHUB.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "uppercase scheme", args: []string{"HTTPS://github.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "files subpage", args: []string{"https://github.example.com/org/repo/pull/123/files"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "commits subpage", args: []string{"https://github.example.com/org/repo/pull/123/commits"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "checks subpage", args: []string{"https://github.example.com/org/repo/pull/123/checks"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "reviews subpage", args: []string{"https://github.example.com/org/repo/pull/123/reviews"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := prTargetFromArgs(tt.args) - if err != nil { - t.Fatalf("prTargetFromArgs() error = %v", err) - } - if got.Path != tt.wantPath || got.Host != tt.wantHost { - t.Fatalf("prTargetFromArgs() = %+v, want path %q host %q", got, tt.wantPath, tt.wantHost) - } - }) - } -} - -func TestResolvePRTargetFromArgsUsesCurrentRepositoryForNumber(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - git(t, dir, "remote", "add", "origin", "git@github.example.com:org/repo.git") - - got, err := resolvePRTargetFromArgs([]string{"123"}, dir) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/123" || got.Host != "github.example.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/123", "github.example.com") - } -} - -func TestResolvePRTargetFromArgsKeepsExplicitTarget(t *testing.T) { - got, err := resolvePRTargetFromArgs([]string{"https://github.com/org/repo/pull/123"}, t.TempDir()) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/123" || got.Host != "github.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/123", "github.com") - } -} - -func TestResolvePRTargetFromArgsNoArgsUsesGH(t *testing.T) { - dir := t.TempDir() - stubGHPRView(t, func(_ context.Context, gotDir string) (string, error) { - if gotDir != dir { - t.Errorf("runGHPRView dir = %q, want %q", gotDir, dir) - } - return "https://github.example.com/org/repo/pull/456\n", nil - }) - - got, err := resolvePRTargetFromArgs(nil, dir) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/456" || got.Host != "github.example.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/456", "github.example.com") - } -} - -func TestResolvePRTargetFromArgsNoArgsErrorsWhenGHFails(t *testing.T) { - stubGHPRView(t, func(context.Context, string) (string, error) { - return "", errors.New("no pull requests found for branch \"feat/x\"") - }) - - _, err := resolvePRTargetFromArgs(nil, t.TempDir()) - if err == nil { - t.Fatal("resolvePRTargetFromArgs() succeeded, want error") - } - if !strings.Contains(err.Error(), "no pull requests found") { - t.Fatalf("resolvePRTargetFromArgs() error = %v, want gh stderr forwarded", err) - } -} - -func TestResolvePRTargetFromArgsNoArgsErrorsWhenURLEmpty(t *testing.T) { - stubGHPRView(t, func(context.Context, string) (string, error) { - return "", nil - }) - - _, err := resolvePRTargetFromArgs(nil, t.TempDir()) - if err == nil { - t.Fatal("resolvePRTargetFromArgs() succeeded, want error") - } - if !strings.Contains(err.Error(), "no pull request found") { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } -} - -func stubGHPRView(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHPRView - runGHPRView = fn - t.Cleanup(func() { runGHPRView = orig }) -} - -func stubGHPRBaseRef(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHPRBaseRef - runGHPRBaseRef = fn - t.Cleanup(func() { runGHPRBaseRef = orig }) -} - -func stubGHRepoDefaultBranch(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHRepoDefaultBranch - runGHRepoDefaultBranch = fn - t.Cleanup(func() { runGHRepoDefaultBranch = orig }) -} - -func ghFails(err string) func(context.Context, string) (string, error) { - return func(context.Context, string) (string, error) { return "", errors.New(err) } -} - -func TestResolveBranchBaseUsesExplicitArgument(t *testing.T) { - stubGHPRBaseRef(t, ghFails("should not be called")) - stubGHRepoDefaultBranch(t, ghFails("should not be called")) - - got, err := resolveBranchBase([]string{"release/v2"}, t.TempDir()) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "release/v2" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "release/v2") - } -} - -func TestBranchTargetIncludesDirtyFlag(t *testing.T) { - if got := branchTarget("origin/main", false); got != "/branch?base=origin%2Fmain" { - t.Fatalf("branchTarget(clean) = %q", got) - } - if got := branchTarget("origin/main", true); got != "/branch?base=origin%2Fmain&dirty=1" { - t.Fatalf("branchTarget(dirty) = %q", got) - } -} - -func TestResolveBranchBasePrefersPRBase(t *testing.T) { - stubGHPRBaseRef(t, func(context.Context, string) (string, error) { return "develop", nil }) - stubGHRepoDefaultBranch(t, ghFails("should not be called")) - - dir := repoWithLocalBranches(t, "develop") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "develop" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "develop") - } -} - -func TestResolveBranchBaseFallsBackToRepoDefault(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR for branch")) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "trunk", nil }) - - dir := repoWithLocalBranches(t, "trunk") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "trunk" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "trunk") - } -} - -func TestResolveBranchBaseFallsBackToOriginRefWhenLocalMissing(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "main", nil }) - - dir := repoWithOriginBranch(t, "main") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "origin/main" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "origin/main") - } -} - -func TestResolveBranchBaseSkipsInferredRefThatDoesNotResolve(t *testing.T) { - stubGHPRBaseRef(t, func(context.Context, string) (string, error) { return "ghost", nil }) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "trunk", nil }) - - dir := repoWithLocalBranches(t, "trunk") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "trunk" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "trunk") - } -} - -func TestResolveBranchBaseFallsBackToMainWhenExists(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "f") - git(t, dir, "commit", "-m", "init") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "main" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "main") - } -} - -func TestResolveBranchBaseFallsBackToMasterWhenMainMissing(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "master") - if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "f") - git(t, dir, "commit", "-m", "init") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "master" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "master") - } -} - -func TestResolveBranchBaseErrorsWhenNothingResolves(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "feature") - - _, err := resolveBranchBase(nil, dir) - if err == nil { - t.Fatal("resolveBranchBase() succeeded, want error") - } - if !strings.Contains(err.Error(), "could not infer base") { - t.Fatalf("resolveBranchBase() error = %v", err) - } -} - -func TestRepoFromRemoteURL(t *testing.T) { - tests := []struct { - name string - remote string - wantHost string - wantOwner string - wantRepo string - wantErr bool - }{ - {name: "https", remote: "https://github.com/org/repo.git", wantHost: "github.com", wantOwner: "org", wantRepo: "repo"}, - {name: "ssh scp", remote: "git@github.com:org/repo.git", wantHost: "github.com", wantOwner: "org", wantRepo: "repo"}, - {name: "ssh url", remote: "ssh://git@github.example.com/org/repo.git", wantHost: "github.example.com", wantOwner: "org", wantRepo: "repo"}, - {name: "missing repo", remote: "https://github.com/org", wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := repoFromRemoteURL(tt.remote) - if tt.wantErr { - if err == nil { - t.Fatal("repoFromRemoteURL() succeeded, want error") - } - return - } - if err != nil { - t.Fatalf("repoFromRemoteURL() error = %v", err) - } - if got.Host != tt.wantHost || got.Owner != tt.wantOwner || got.Name != tt.wantRepo { - t.Fatalf("repoFromRemoteURL() = %+v, want host %q owner %q repo %q", got, tt.wantHost, tt.wantOwner, tt.wantRepo) - } - }) - } -} - -func TestResolveGitHubHostPrefersURLHostWhenFlagOmitted(t *testing.T) { - cmd := newRootCommand(time.Time{}) - prCmd, _, err := cmd.Find([]string{"pr"}) - if err != nil { - t.Fatal(err) - } - opts := &cliOptions{ghHost: "github.com"} - - got := opts.withResolvedGitHubHost(prCmd, "github.example.com") - if got.ghHost != "github.example.com" { - t.Fatalf("ghHost = %q, want URL host", got.ghHost) - } - if opts.ghHost != "github.com" { - t.Fatalf("original ghHost mutated to %q", opts.ghHost) - } -} - -func TestResolveGitHubHostKeepsExplicitFlag(t *testing.T) { - cmd := newRootCommand(time.Time{}) - prCmd, _, err := cmd.Find([]string{"pr"}) - if err != nil { - t.Fatal(err) - } - if err := prCmd.Flags().Set("gh-host", "explicit.example.com"); err != nil { - t.Fatal(err) - } - opts := &cliOptions{ghHost: "explicit.example.com"} - - got := opts.withResolvedGitHubHost(prCmd, "url.example.com") - if got.ghHost != "explicit.example.com" { - t.Fatalf("ghHost = %q, want explicit flag host", got.ghHost) - } -} - -func TestTargetPathFromArgsRejectsInvalidTarget(t *testing.T) { - tests := [][]string{ - nil, - {""}, - {"org/repo/issues/123"}, - {"http:///org/repo/pull/123"}, - {"https://github.example.com/org/repo/pull/123/random"}, - {"https://github.example.com/org/repo/pull/123/files/1"}, - } - for _, args := range tests { - if _, err := prTargetFromArgs(args); err == nil { - t.Fatalf("prTargetFromArgs(%v) succeeded, want error", args) - } - } -} - -func TestRootCommandRejectsDirectPRTarget(t *testing.T) { - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"/org/repo/pull/123"}) - if err := cmd.Execute(); err == nil { - t.Fatal("root command accepted direct PR target, want explicit pr subcommand") - } -} - -func TestUnknownCommandPrintsRootHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&out) - cmd.SetArgs([]string{"bogus"}) - - err := executeRootCommand(cmd) - if err == nil { - t.Fatal("unknown command succeeded, want error") - } - var quiet quietError - if !errors.As(err, &quiet) { - t.Fatalf("error = %T, want quietError", err) - } - - got := out.String() - for _, want := range []string{ - `unknown command "bogus" for "diffs"`, - "Usage:", - "diffs [flags]", - "Available Commands:", - "branch", - "pr", - "version", - } { - if !strings.Contains(got, want) { - t.Fatalf("unknown command output missing %q in:\n%s", want, got) - } - } -} - -func TestLocalCommandRejectsNonGitRepository(t *testing.T) { - dir := t.TempDir() - var errOut bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&errOut) - cmd.SetErr(&errOut) - cmd.SetArgs([]string{"--dir", dir, "--no-open"}) - err := cmd.Execute() - if err == nil { - t.Fatal("local command succeeded outside git repository") - } - if !strings.Contains(err.Error(), "not a git repository") { - t.Fatalf("error = %v, want not a git repository", err) - } - got := errOut.String() - for _, want := range []string{ - "error not a git repository: " + dir, - "hint run from a git repository", - "hint or pass --dir /path/to/repo", - "hint or use diffs pr /org/repo/pull/123", - "Usage:", - "diffs [flags]", - "Available Commands:", - "branch", - "pr", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("git help missing %q in:\n%s", want, got) - } - } -} - -func TestBranchCommandRejectsNonGitRepository(t *testing.T) { - for _, args := range [][]string{ - {"--dir", t.TempDir(), "branch", "main", "--no-open"}, - {"--dir", t.TempDir(), "branch", "--no-open"}, - } { - t.Run(strings.Join(args, " "), func(t *testing.T) { - var errOut bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&errOut) - cmd.SetErr(&errOut) - cmd.SetArgs(args) - err := cmd.Execute() - if err == nil { - t.Fatal("branch command succeeded outside git repository") - } - if !strings.Contains(err.Error(), "not a git repository") { - t.Fatalf("error = %v, want not a git repository", err) - } - got := errOut.String() - for _, want := range []string{ - "error not a git repository: " + args[1], - "hint run from a git repository", - "Usage:", - "diffs branch [base]", - } { - if !strings.Contains(got, want) { - t.Fatalf("branch git help missing %q in:\n%s", want, got) - } - } - }) - } -} - -func TestGitRootAcceptsGitRepository(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - - got, err := gitRoot(dir) - if err != nil { - t.Fatalf("gitRoot() error = %v", err) - } - want, err := filepath.EvalSymlinks(dir) - if err != nil { - t.Fatalf("EvalSymlinks() error = %v", err) - } - if got != want { - t.Fatalf("gitRoot() = %q, want %q", got, want) - } -} - -func TestNormalizeListenAddrPrefersIPv4Loopback(t *testing.T) { - if got := normalizeListenAddr("localhost:3433"); got != "127.0.0.1:3433" { - t.Fatalf("normalizeListenAddr() = %q, want %q", got, "127.0.0.1:3433") - } -} - -func TestListenAddrFromOptionsUsesHostAndPort(t *testing.T) { - got, err := listenAddrFromOptions("localhost", 4321) - if err != nil { - t.Fatalf("listenAddrFromOptions() error = %v", err) - } - if got != "127.0.0.1:4321" { - t.Fatalf("listenAddrFromOptions() = %q, want %q", got, "127.0.0.1:4321") - } -} - -func TestListenAddrFromOptionsRejectsInvalidPort(t *testing.T) { - if _, err := listenAddrFromOptions("127.0.0.1", 70000); err == nil { - t.Fatal("expected invalid port to fail") - } -} - -func TestListenWithPortFallbackUsesRandomPortWhenBusy(t *testing.T) { - occupied, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen occupied port: %v", err) - } - defer func() { _ = occupied.Close() }() - - ln, fallback, err := listenWithPortFallback(occupied.Addr().String()) - if err != nil { - t.Fatalf("listenWithPortFallback() error = %v", err) - } - defer func() { _ = ln.Close() }() - if fallback == nil { - t.Fatal("listenWithPortFallback() fallback = nil, want fallback") - } - if fallback.Requested != occupied.Addr().String() { - t.Fatalf("fallback requested = %q, want %q", fallback.Requested, occupied.Addr().String()) - } - if fallback.Actual != ln.Addr().String() { - t.Fatalf("fallback actual = %q, want %q", fallback.Actual, ln.Addr().String()) - } - if fallback.Actual == fallback.Requested { - t.Fatalf("fallback reused busy address %q", fallback.Actual) - } -} - -func TestBrowserURLUsesLoopbackForWildcard(t *testing.T) { - got := browserURL(&net.TCPAddr{IP: net.IPv4zero, Port: 3433}, "/local") - if got != "http://127.0.0.1:3433/local" { - t.Fatalf("browserURL() = %q, want %q", got, "http://127.0.0.1:3433/local") - } -} - -func TestIsLocalGitTarget(t *testing.T) { - tests := []struct { - path string - want bool - }{ - {path: "/local", want: true}, - {path: "/branch", want: true}, - {path: "/branch?base=main", want: true}, - {path: "/org/repo/pull/123", want: false}, - {path: "/branching", want: false}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := isLocalGitTarget(tt.path); got != tt.want { - t.Fatalf("isLocalGitTarget() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestTargetLabel(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "checkout", "-b", "feature/startup") - - tests := []struct { - path string - cwd string - want string - }{ - {path: "/local", cwd: dir, want: "feature/startup"}, - {path: "/branch?base=origin%2Fmain", cwd: dir, want: "feature/startup -> origin/main"}, - {path: "/org/repo/pull/123", cwd: dir, want: "GitHub PR org/repo#123"}, - {path: "/local", cwd: filepath.Join(dir, "missing"), want: "local repository"}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := targetLabel(tt.path, tt.cwd); got != tt.want { - t.Fatalf("targetLabel() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestPrintStartup(t *testing.T) { - var out bytes.Buffer - printStartup(&out, startupInfo{ - URL: "http://127.0.0.1:3433/local", - Target: "feature/startup", - CWD: "/repo", - Watching: true, - Elapsed: 12 * time.Millisecond, - }, false) - - got := out.String() - for _, want := range []string{ - "diffs ready in 12 ms", - "serve http://127.0.0.1:3433/local", - "target feature/startup", - "watch /repo", - "stop Ctrl+C", - } { - if !strings.Contains(got, want) { - t.Fatalf("printStartup() missing %q in:\n%s", want, got) - } - } -} - -func TestPrintPortFallback(t *testing.T) { - var out bytes.Buffer - printPortFallback(&out, "127.0.0.1:3433", "127.0.0.1:52624", false) - got := out.String() - if !strings.Contains(got, "warn 127.0.0.1:3433 in use; using 127.0.0.1:52624") { - t.Fatalf("printPortFallback() = %q", got) - } -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} - -func repoWithLocalBranches(t *testing.T, branches ...string) string { - t.Helper() - if len(branches) == 0 { - t.Fatal("repoWithLocalBranches requires at least one branch name") - } - dir := t.TempDir() - git(t, dir, "init", "-b", branches[0]) - if err := os.WriteFile(filepath.Join(dir, "seed"), []byte("seed\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "seed") - git(t, dir, "commit", "-m", "init") - for _, b := range branches[1:] { - git(t, dir, "branch", b) - } - return dir -} - -func repoWithOriginBranch(t *testing.T, branch string) string { - t.Helper() - dir := t.TempDir() - git(t, dir, "init", "-b", "feature") - if err := os.WriteFile(filepath.Join(dir, "seed"), []byte("seed\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "seed") - git(t, dir, "commit", "-m", "init") - git(t, dir, "update-ref", "refs/remotes/origin/"+branch, "HEAD") - return dir -} - -func TestPrintReload(t *testing.T) { - var out bytes.Buffer - printReload(&out, time.Date(2026, 5, 23, 14, 15, 16, 0, time.Local), []server.ChangedFile{{Action: server.ChangeModified, Path: "web/src/App.tsx"}}, false) - - got := out.String() - for _, want := range []string{"modified web/src/App.tsx"} { - if !strings.Contains(got, want) { - t.Fatalf("printReload() missing %q in %q", want, got) - } - } - if strings.Contains(got, "(+") || strings.Contains(got, " -") { - t.Fatalf("printReload() should not include line stats: %q", got) - } - if strings.Contains(got, "14:15:16") || strings.Contains(got, "[diffs]") || strings.Contains(got, "reload") || strings.Contains(got, "change") { - t.Fatalf("printReload() should not include timestamp, bracketed prefix, or extra reload line: %q", got) - } -} - -func TestReloadLoggerCoalescesBursts(t *testing.T) { - var out bytes.Buffer - reload := newReloadLogger(&out, false) - now := time.Date(2026, 5, 23, 14, 15, 16, 0, time.Local) - - reload(now, []server.ChangedFile{{Action: server.ChangeModified, Path: "one.go"}}) - reload(now.Add(100*time.Millisecond), []server.ChangedFile{{Action: server.ChangeModified, Path: "two.go"}}) - reload(now.Add(600*time.Millisecond), []server.ChangedFile{{Action: server.ChangeModified, Path: "three.go"}}) - - if got := strings.Count(out.String(), "modified"); got != 2 { - t.Fatalf("reload log count = %d, want 2:\n%s", got, out.String()) - } -} - -func TestReloadLineSummarizesMultiplePaths(t *testing.T) { - label, message := reloadLine([]server.ChangedFile{ - {Action: server.ChangeAdded, Path: "a.go"}, - {Action: server.ChangeModified, Path: "b.go"}, - {Action: server.ChangeDeleted, Path: "c.go"}, - }, terminalColors{}, false) - if label != "added" || message != "a.go (+2 more)" { - t.Fatalf("reloadLine() = %q, %q; want added, a.go (+2 more)", label, message) - } -} - -func TestReloadLineColorsPath(t *testing.T) { - c := terminalColors{cyan: "C", reset: "Z"} - label, message := reloadLine([]server.ChangedFile{ - {Action: server.ChangeModified, Path: "a.go"}, - }, c, true) - want := "Ca.goZ" - if label != "modified" || message != want { - t.Fatalf("reloadLine() = %q, %q; want modified, %q", label, message, want) - } -} - -func TestReloadLineFallsBackToChangeLabel(t *testing.T) { - label, message := reloadLine([]server.ChangedFile{ - {Path: "a.go"}, - {Path: "b.go"}, - {Path: "c.go"}, - }, terminalColors{}, false) - want := "a.go (+2 more)" - if label != "change" || message != want { - t.Fatalf("reloadLine() = %q, %q; want change, %q", label, message, want) - } -} - -func TestLatestCommentBodyTruncatesUTF8Safely(t *testing.T) { - body := strings.Repeat("评", 80) + " done" - got := latestCommentBody(comments.Thread{ - Comments: []comments.Comment{{Body: body}}, - }) - if !utf8.ValidString(got) { - t.Fatalf("latestCommentBody() returned invalid UTF-8: %q", got) - } - if strings.Count(got, "评") != 69 || !strings.HasSuffix(got, "...") { - t.Fatalf("latestCommentBody() = %q, want 69 runes plus ellipsis", got) - } -} - -func TestRootCommandHelpShowsSubcommandsAndDir(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs [flags]", - "branch", - "pr", - "version", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("help output missing %q in:\n%s", want, got) - } - } - if strings.Contains(got, "--github-host") { - t.Fatalf("root help output should not include pr-only flag --github-host:\n%s", got) - } - if strings.Contains(got, "--gh-host") { - t.Fatalf("root help output should not include pr-only flag --gh-host:\n%s", got) - } -} - -func TestPRCommandHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"pr", "--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("pr help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs pr [number|github-pr-url|/org/repo/pull/123]", - "--host string", - "--gh-host string", - "--port int", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("pr help output missing %q in:\n%s", want, got) - } - } - if strings.Contains(got, "--github-host") { - t.Fatalf("pr help output should not include removed flag --github-host:\n%s", got) - } -} - -func TestBranchCommandHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"branch", "--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("branch help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs branch [base]", - "--include-dirty", - "--host string", - "--port int", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("branch help output missing %q in:\n%s", want, got) - } - } -} - -func TestVersionCommandPrintsDefaultDevVersion(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"version"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("version failed: %v", err) - } - - if got := out.String(); got != "dev\n" { - t.Fatalf("version output = %q, want %q", got, "dev\n") - } -} - -func TestCommentsCommandAddAndListJSON(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - - var addOut bytes.Buffer - addCmd := newRootCommand(time.Time{}) - addCmd.SetOut(&addOut) - addCmd.SetErr(&bytes.Buffer{}) - addCmd.SetArgs([]string{ - "--dir", dir, - "comments", "--json", "add", - "--file", "web/src/App.tsx", - "--line", "42", - "--body", "Looks suspicious", - "--author", "agent", - }) - if err := addCmd.Execute(); err != nil { - t.Fatalf("comments add failed: %v", err) - } - var added struct { - ID string `json:"id"` - Path string `json:"path"` - Line int `json:"line"` - Status string `json:"status"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.Unmarshal(addOut.Bytes(), &added); err != nil { - t.Fatalf("decode add json: %v\n%s", err, addOut.String()) - } - if added.ID == "" || added.Path != "web/src/App.tsx" || added.Line != 42 || added.Status != "open" { - t.Fatalf("unexpected added thread: %+v", added) - } - if len(added.Comments) != 1 || added.Comments[0].Author != "agent" || added.Comments[0].Body != "Looks suspicious" { - t.Fatalf("unexpected added comments: %+v", added.Comments) - } - - var listOut bytes.Buffer - listCmd := newRootCommand(time.Time{}) - listCmd.SetOut(&listOut) - listCmd.SetErr(&bytes.Buffer{}) - listCmd.SetArgs([]string{"--dir", dir, "comments", "--json", "list"}) - if err := listCmd.Execute(); err != nil { - t.Fatalf("comments list failed: %v", err) - } - var listed struct { - Threads []struct { - ID string `json:"id"` - } `json:"threads"` - } - if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { - t.Fatalf("decode list json: %v\n%s", err, listOut.String()) - } - if len(listed.Threads) != 1 || listed.Threads[0].ID != added.ID { - t.Fatalf("listed threads = %+v, want %s", listed.Threads, added.ID) - } -} diff --git a/cmd/diffs/output.go b/cmd/diffs/output.go deleted file mode 100644 index 445ac36..0000000 --- a/cmd/diffs/output.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "sync" - "time" - - "github.com/imfing/diffs-cli/internal/server" - "golang.org/x/term" -) - -const reloadDebounce = 500 * time.Millisecond - -type startupInfo struct { - URL string - Target string - CWD string - Watching bool - Elapsed time.Duration -} - -type terminalColors struct { - reset string - bold string - dim string - green string - cyan string - yellow string - red string - magenta string -} - -type quietError struct { - err error -} - -func (e quietError) Error() string { - return e.err.Error() -} - -func (e quietError) Unwrap() error { - return e.err -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { - return false - } - f, ok := w.(*os.File) - if !ok { - return false - } - return term.IsTerminal(int(f.Fd())) -} - -func colors(enabled bool) terminalColors { - if !enabled { - return terminalColors{} - } - return terminalColors{ - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - green: "\x1b[32m", - cyan: "\x1b[36m", - yellow: "\x1b[33m", - red: "\x1b[31m", - magenta: "\x1b[35m", - } -} - -func printStartup(w io.Writer, info startupInfo, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLine(w, c, "diffs", fmt.Sprintf("ready in %s", formatReadyDuration(info.Elapsed))) - printLogLine(w, c, "serve", colorize(info.URL, c.cyan, c.reset)) - printLogLine(w, c, "target", info.Target) - if info.Watching { - printLogLine(w, c, "watch", info.CWD) - } - printLogLine(w, c, "stop", colorize("Ctrl+C", c.dim, c.reset)) - _, _ = fmt.Fprintln(w) -} - -func printPortFallback(w io.Writer, requested, actual string, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLineColor(w, c, "warn", fmt.Sprintf("%s in use; using %s", requested, actual), c.yellow) -} - -func printReload(w io.Writer, _ time.Time, files []server.ChangedFile, color bool) { - c := colors(color) - label, message := reloadLine(files, c, color) - printLogLineColor(w, c, label, message, reloadLabelColor(label, c)) -} - -func printLocalGitHelp(w io.Writer, dir string, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLine(w, c, "error", fmt.Sprintf("not a git repository: %s", dir)) - printLogLine(w, c, "hint", "run from a git repository") - printLogLine(w, c, "hint", "or pass --dir /path/to/repo") - printLogLine(w, c, "hint", "or use diffs pr /org/repo/pull/123") - _, _ = fmt.Fprintln(w) -} - -func reloadLine(files []server.ChangedFile, c terminalColors, color bool) (string, string) { - if len(files) == 0 { - return "change", "local changes" - } - - action := files[0].Action - label := string(action) - if action == "" { - label = "change" - } - path := files[0].Path - if color { - path = c.cyan + path + c.reset - } - if len(files) == 1 { - return label, path - } - return label, fmt.Sprintf("%s (+%d more)", path, len(files)-1) -} - -func reloadLabelColor(label string, c terminalColors) string { - switch label { - case string(server.ChangeAdded): - return c.green - case string(server.ChangeModified): - return c.yellow - case string(server.ChangeDeleted): - return c.red - case string(server.ChangeRenamed): - return c.magenta - default: - return c.green - } -} - -func newReloadLogger(w io.Writer, color bool) func(time.Time, []server.ChangedFile) { - var mu sync.Mutex - var last time.Time - return func(now time.Time, files []server.ChangedFile) { - mu.Lock() - defer mu.Unlock() - if !last.IsZero() && now.Sub(last) < reloadDebounce { - return - } - last = now - printReload(w, now, files, color) - } -} - -func formatReadyDuration(d time.Duration) string { - ms := d.Round(time.Millisecond).Milliseconds() - if ms < 1 { - ms = 1 - } - return fmt.Sprintf("%d ms", ms) -} - -func printLogLineColor(w io.Writer, c terminalColors, label string, message string, color string) { - if color == "" { - color = c.green - } - _, _ = fmt.Fprintf(w, " %s%-8s%s %s\n", color, label, c.reset, message) -} - -func printLogLine(w io.Writer, c terminalColors, label string, message string) { - printLogLineColor(w, c, label, message, "") -} - -func colorize(text, color, reset string) string { - if color == "" { - return text - } - return color + text + reset -} diff --git a/cmd/diffs/root.go b/cmd/diffs/root.go deleted file mode 100644 index 7495321..0000000 --- a/cmd/diffs/root.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "os" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/server" - "github.com/spf13/cobra" -) - -const ( - defaultHost = "127.0.0.1" - defaultPort = 3433 - defaultDir = "." -) - -type cliOptions struct { - host string - port int - ghHost string - dir string - noOpen bool -} - -func newRootCommand(started time.Time) *cobra.Command { - opts := &cliOptions{ - host: defaultHost, - port: defaultPort, - ghHost: defaultGithubHost(), - dir: defaultDir, - } - root := &cobra.Command{ - Use: "diffs [flags]", - Short: "Review local diffs and GitHub pull requests in a browser", - SilenceErrors: true, - SilenceUsage: true, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return runServerTarget(cmd, opts, "/local", started) - }, - } - root.PersistentFlags().StringVar(&opts.dir, "dir", opts.dir, "repository directory for local diff and comments") - addServeFlags(root, opts, false) - root.AddCommand( - newPRCommand(opts, started), - newBranchCommand(opts, started), - newCommentsCommand(opts), - newVersionCommand(), - ) - return root -} - -func newPRCommand(opts *cliOptions, started time.Time) *cobra.Command { - cmd := &cobra.Command{ - Use: "pr [number|github-pr-url|/org/repo/pull/123]", - Short: "Review a GitHub pull request", - Long: "Review a GitHub pull request. With no argument, resolves the PR associated with the current branch via `gh pr view`.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - target, err := resolvePRTargetFromArgs(args, opts.dir) - if err != nil { - return err - } - return runServerTarget(cmd, opts.withResolvedGitHubHost(cmd, target.Host), target.Path, started) - }, - } - addServeFlags(cmd, opts, true) - return cmd -} - -func addServeFlags(cmd *cobra.Command, opts *cliOptions, includeGHHost bool) { - cmd.Flags().StringVar(&opts.host, "host", opts.host, "host to serve the review UI on") - cmd.Flags().IntVar(&opts.port, "port", opts.port, "port to serve the review UI on") - if includeGHHost { - cmd.Flags().StringVar(&opts.ghHost, "gh-host", opts.ghHost, "GitHub host used by gh api") - } - cmd.Flags().BoolVar(&opts.noOpen, "no-open", false, "do not open the browser automatically") -} - -func defaultGithubHost() string { - if host := strings.TrimSpace(os.Getenv("GH_HOST")); host != "" { - return host - } - return server.DefaultGitHubHost -} - -func (opts *cliOptions) withResolvedGitHubHost(cmd *cobra.Command, targetHost string) *cliOptions { - next := *opts - if strings.TrimSpace(targetHost) != "" && !cmd.Flags().Changed("gh-host") { - next.ghHost = targetHost - } - return &next -} diff --git a/cmd/diffs/serve.go b/cmd/diffs/serve.go deleted file mode 100644 index 3dbd6d6..0000000 --- a/cmd/diffs/serve.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/appconfig" - "github.com/imfing/diffs-cli/internal/server" - "github.com/spf13/cobra" -) - -func runServerTarget(cmd *cobra.Command, opts *cliOptions, targetPath string, started time.Time) error { - if started.IsZero() { - started = time.Now() - } - out := cmd.OutOrStdout() - errOut := cmd.ErrOrStderr() - - displayCWD, err := filepath.Abs(opts.dir) - if err != nil { - return err - } - localGitTarget := isLocalGitTarget(targetPath) - if localGitTarget { - root, err := gitRoot(displayCWD) - if err != nil { - printLocalGitHelp(errOut, displayCWD, colorEnabled(errOut)) - _ = cmd.Help() - return quietError{err: err} - } - displayCWD = root - opts.dir = root - } - appCfg, err := appconfig.LoadDefault() - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - cfg := server.Config{ - CWD: opts.dir, - GitHubHost: opts.ghHost, - UI: appCfg.UI, - Watch: localGitTarget, - } - if localGitTarget { - reload := newReloadLogger(out, colorEnabled(out)) - cfg.OnChange = func(files []server.ChangedFile) { - reload(time.Now(), files) - } - } - handler, err := server.New(cfg) - if err != nil { - return err - } - - srv := &http.Server{ - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, - } - listenAddr, err := listenAddrFromOptions(opts.host, opts.port) - if err != nil { - return err - } - ln, fallback, err := listenWithPortFallback(listenAddr) - if err != nil { - return err - } - url := browserURL(ln.Addr(), targetPath) - if fallback != nil { - printPortFallback(out, fallback.Requested, fallback.Actual, colorEnabled(out)) - } - printStartup(out, startupInfo{ - URL: url, - Target: targetLabel(targetPath, displayCWD), - CWD: displayCWD, - Watching: localGitTarget, - Elapsed: time.Since(started), - }, colorEnabled(out)) - - if !opts.noOpen { - if err := openBrowser(url); err != nil { - _, _ = fmt.Fprintf(errOut, "warning: could not open browser: %v\n", err) - } - } - - if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { - return err - } - return nil -} - -func isLocalGitTarget(targetPath string) bool { - return targetPath == "/local" || targetPath == "/branch" || strings.HasPrefix(targetPath, "/branch?") -} diff --git a/cmd/diffs/target.go b/cmd/diffs/target.go deleted file mode 100644 index f3e09ac..0000000 --- a/cmd/diffs/target.go +++ /dev/null @@ -1,217 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "syscall" -) - -type listenFallback struct { - Requested string - Actual string -} - -type prTarget struct { - Path string - Host string -} - -func listenAddrFromOptions(host string, port int) (string, error) { - if port < 0 || port > 65535 { - return "", fmt.Errorf("port must be between 0 and 65535") - } - host = strings.TrimSpace(host) - if host == "" { - host = "127.0.0.1" - } - return normalizeListenAddr(net.JoinHostPort(host, strconv.Itoa(port))), nil -} - -func listenWithPortFallback(addr string) (net.Listener, *listenFallback, error) { - ln, err := net.Listen("tcp", addr) - if err == nil { - return ln, nil, nil - } - if !isAddrInUse(err) { - return nil, nil, err - } - fallbackAddr, ok := randomPortAddr(addr) - if !ok { - return nil, nil, err - } - ln, fallbackErr := net.Listen("tcp", fallbackAddr) - if fallbackErr != nil { - return nil, nil, fmt.Errorf("%w; fallback to a random port failed: %v", err, fallbackErr) - } - return ln, &listenFallback{Requested: addr, Actual: ln.Addr().String()}, nil -} - -func randomPortAddr(addr string) (string, bool) { - host, port, err := net.SplitHostPort(addr) - if err != nil || port == "0" { - return "", false - } - return net.JoinHostPort(host, "0"), true -} - -func isAddrInUse(err error) bool { - if errors.Is(err, syscall.EADDRINUSE) { - return true - } - message := strings.ToLower(err.Error()) - return strings.Contains(message, "address already in use") || - strings.Contains(message, "only one usage of each socket address") -} - -func normalizeListenAddr(addr string) string { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return addr - } - if host == "localhost" { - return net.JoinHostPort("127.0.0.1", port) - } - return addr -} - -func browserURL(addr net.Addr, targetPath string) string { - host, port, err := net.SplitHostPort(addr.String()) - if err != nil { - return "http://" + addr.String() + targetPath - } - if host == "" || host == "::" || host == "0.0.0.0" { - host = "127.0.0.1" - } - return "http://" + net.JoinHostPort(host, port) + targetPath -} - -func prTargetFromArgs(args []string) (prTarget, error) { - if len(args) != 1 || strings.TrimSpace(args[0]) == "" { - return prTarget{}, fmt.Errorf("expected one GitHub PR target") - } - target := strings.TrimSpace(args[0]) - host := "" - lowerTarget := strings.ToLower(target) - if strings.HasPrefix(lowerTarget, "http://") || strings.HasPrefix(lowerTarget, "https://") { - req, err := http.NewRequest(http.MethodGet, target, nil) - if err != nil { - return prTarget{}, err - } - host = strings.ToLower(req.URL.Hostname()) - if host == "" { - return prTarget{}, fmt.Errorf("target URL must include a host") - } - target = req.URL.Path - } - if !strings.HasPrefix(target, "/") { - target = "/" + target - } - parts := strings.Split(strings.Trim(target, "/"), "/") - if len(parts) >= 4 && parts[2] == "pull" && parts[3] != "" { - if len(parts) == 4 || isPullRequestSubpage(parts[4:]) { - return prTarget{Path: "/" + strings.Join(parts[:4], "/"), Host: host}, nil - } - } - return prTarget{}, fmt.Errorf("target must be a GitHub PR URL or /org/repo/pull/123") -} - -func resolvePRTargetFromArgs(args []string, dir string) (prTarget, error) { - if len(args) == 0 { - url, err := currentBranchPRURL(dir) - if err != nil { - return prTarget{}, err - } - return prTargetFromArgs([]string{url}) - } - target, ok := prNumberFromArgs(args) - if !ok { - return prTargetFromArgs(args) - } - - remote, err := gitRemoteURL(dir, "origin") - if err != nil { - return prTarget{}, fmt.Errorf("resolve current repository for PR #%s: %w", target, err) - } - repo, err := repoFromRemoteURL(remote) - if err != nil { - return prTarget{}, fmt.Errorf("resolve current repository for PR #%s: %w", target, err) - } - return prTarget{ - Path: fmt.Sprintf("/%s/%s/pull/%s", repo.Owner, repo.Name, target), - Host: repo.Host, - }, nil -} - -func prNumberFromArgs(args []string) (string, bool) { - if len(args) != 1 { - return "", false - } - target := strings.TrimSpace(args[0]) - n, err := strconv.Atoi(target) - if err != nil || n <= 0 { - return "", false - } - return target, true -} - -type remoteRepo struct { - Host string - Owner string - Name string -} - -func repoFromRemoteURL(remote string) (remoteRepo, error) { - remote = strings.TrimSpace(remote) - if remote == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL is empty") - } - var host, path string - if strings.Contains(remote, "://") { - u, err := url.Parse(remote) - if err != nil { - return remoteRepo{}, err - } - host = u.Hostname() - if host == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL must include a host") - } - path = u.Path - } else { - userHost, scpPath, ok := strings.Cut(remote, ":") - if !ok || strings.Contains(userHost, "/") { - return remoteRepo{}, fmt.Errorf("origin remote URL must be an absolute URL or SCP-style remote") - } - host = userHost - if _, after, ok := strings.Cut(userHost, "@"); ok { - host = after - } - path = scpPath - } - host = strings.ToLower(host) - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) < 2 { - return remoteRepo{}, fmt.Errorf("origin remote URL must include owner and repository") - } - name := strings.TrimSuffix(parts[1], ".git") - if parts[0] == "" || name == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL must include owner and repository") - } - return remoteRepo{Host: host, Owner: parts[0], Name: name}, nil -} - -func isPullRequestSubpage(parts []string) bool { - if len(parts) != 1 { - return false - } - switch parts[0] { - case "checks", "commits", "files", "reviews": - return true - default: - return false - } -} diff --git a/cmd/diffs/version.go b/cmd/diffs/version.go deleted file mode 100644 index 4b5625d..0000000 --- a/cmd/diffs/version.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var version = "dev" - -func newVersionCommand() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print the diffs version", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintln(cmd.OutOrStdout(), version) - return err - }, - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 965d1d3..0000000 --- a/go.mod +++ /dev/null @@ -1,224 +0,0 @@ -module github.com/imfing/diffs-cli - -go 1.26 - -require ( - github.com/fsnotify/fsnotify v1.10.1 - github.com/pelletier/go-toml/v2 v2.3.1 - github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.43.0 -) - -require ( - 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect - 4d63.com/gochecknoglobals v0.2.2 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect - codeberg.org/chavacava/garif v0.2.0 // indirect - codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect - dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect - dev.gaijin.team/go/golib v0.6.0 // indirect - github.com/4meepo/tagalign v1.4.3 // indirect - github.com/Abirdcfly/dupword v0.1.7 // indirect - github.com/AdminBenni/iota-mixing v1.0.0 // indirect - github.com/AlwxSin/noinlineerr v1.0.5 // indirect - github.com/Antonboom/errname v1.1.1 // indirect - github.com/Antonboom/nilnil v1.1.1 // indirect - github.com/Antonboom/testifylint v1.6.4 // indirect - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect - github.com/Djarvur/go-err113 v0.1.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/MirrexOne/unqueryvet v1.5.4 // indirect - github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/alecthomas/chroma/v2 v2.24.1 // indirect - github.com/alecthomas/go-check-sumtype v0.3.1 // indirect - github.com/alexkohler/nakedret/v2 v2.0.6 // indirect - github.com/alexkohler/prealloc v1.1.0 // indirect - github.com/alfatraining/structtag v1.0.0 // indirect - github.com/alingse/asasalint v0.0.11 // indirect - github.com/alingse/nilnesserr v0.2.0 // indirect - github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect - github.com/ashanbrown/makezero/v2 v2.2.1 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blizzy78/varnamelen v0.8.0 // indirect - github.com/bombsimon/wsl/v4 v4.7.0 // indirect - github.com/bombsimon/wsl/v5 v5.8.0 // indirect - github.com/breml/bidichk v0.3.3 // indirect - github.com/breml/errchkjson v0.4.1 // indirect - github.com/butuzov/ireturn v0.4.1 // indirect - github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.10.1 // indirect - github.com/ccojocar/zxcvbn-go v1.0.4 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.11 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/ckaznocha/intrange v0.3.1 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/curioswitch/go-reassign v0.3.0 // indirect - github.com/daixiang0/gci v0.13.7 // indirect - github.com/dave/dst v0.27.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/ettle/strcase v0.2.0 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/fatih/structtag v1.2.0 // indirect - github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/ghostiam/protogetter v0.3.20 // indirect - github.com/go-critic/go-critic v0.14.3 // indirect - github.com/go-toolsmith/astcast v1.1.0 // indirect - github.com/go-toolsmith/astcopy v1.1.0 // indirect - github.com/go-toolsmith/astequal v1.2.0 // indirect - github.com/go-toolsmith/astfmt v1.1.0 // indirect - github.com/go-toolsmith/astp v1.1.0 // indirect - github.com/go-toolsmith/strparse v1.1.0 // indirect - github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/godoc-lint/godoc-lint v0.11.2 // indirect - github.com/gofrs/flock v0.13.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/golangci/asciicheck v0.5.0 // indirect - github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect - github.com/golangci/go-printf-func-name v0.1.1 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint/v2 v2.12.0 // indirect - github.com/golangci/golines v0.15.0 // indirect - github.com/golangci/misspell v0.8.0 // indirect - github.com/golangci/plugin-module-register v0.1.2 // indirect - github.com/golangci/revgrep v0.8.0 // indirect - github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect - github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect - github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/gordonklaus/ineffassign v0.2.0 // indirect - github.com/gostaticanalysis/analysisutil v0.7.1 // indirect - github.com/gostaticanalysis/comment v1.5.0 // indirect - github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.2 // indirect - github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect - github.com/hashicorp/go-version v1.9.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jgautheron/goconst v1.10.0 // indirect - github.com/jjti/go-spancheck v0.6.5 // indirect - github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect - github.com/kisielk/errcheck v1.10.0 // indirect - github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/kulti/thelper v0.7.1 // indirect - github.com/kunwardeep/paralleltest v1.0.15 // indirect - github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.5 // indirect - github.com/ldez/gomoddirectives v0.8.0 // indirect - github.com/ldez/grignotin v0.10.1 // indirect - github.com/ldez/structtags v0.6.1 // indirect - github.com/ldez/tagliatelle v0.7.2 // indirect - github.com/ldez/usetesting v0.5.0 // indirect - github.com/leonklingele/grouper v1.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/macabu/inamedparam v0.2.0 // indirect - github.com/magiconair/properties v1.8.6 // indirect - github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect - github.com/manuelarte/funcorder v0.6.0 // indirect - github.com/maratori/testableexamples v1.0.1 // indirect - github.com/maratori/testpackage v1.1.2 // indirect - github.com/matoous/godox v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/mgechev/revive v1.15.0 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moricho/tparallel v0.3.2 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/nakabonne/nestif v0.3.1 // indirect - github.com/nishanths/exhaustive v0.12.0 // indirect - github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.23.0 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.12.1 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.32.1 // indirect - github.com/prometheus/procfs v0.7.3 // indirect - github.com/quasilyte/go-ruleguard v0.4.5 // indirect - github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect - github.com/quasilyte/gogrep v0.5.0 // indirect - github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect - github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect - github.com/raeperd/recvcheck v0.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/ryancurrah/gomodguard v1.4.1 // indirect - github.com/ryancurrah/gomodguard/v2 v2.1.0 // indirect - github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect - github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect - github.com/securego/gosec/v2 v2.26.1 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - github.com/sivchari/containedctx v1.0.3 // indirect - github.com/sonatard/noctx v0.5.1 // indirect - github.com/sourcegraph/go-diff v0.8.0 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.5.0 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.12.0 // indirect - github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/subosito/gotenv v1.4.1 // indirect - github.com/tetafro/godot v1.5.6 // indirect - github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect - github.com/timonwong/loggercheck v0.11.0 // indirect - github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect - github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect - github.com/ultraware/funlen v0.2.0 // indirect - github.com/ultraware/whitespace v0.2.0 // indirect - github.com/uudashr/gocognit v1.2.1 // indirect - github.com/uudashr/iface v1.4.1 // indirect - github.com/xen0n/gosmopolitan v1.3.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yagipy/maintidx v1.0.0 // indirect - github.com/yeya24/promlinter v0.3.0 // indirect - github.com/ykadowak/zerologlint v0.1.5 // indirect - gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.14.0 // indirect - go-simpler.org/sloglint v0.12.0 // indirect - go.augendre.info/arangolint v0.4.0 // indirect - go.augendre.info/fatcontext v0.9.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - honnef.co/go/tools v0.7.0 // indirect - mvdan.cc/gofumpt v0.9.2 // indirect - mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect -) - -tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint diff --git a/go.sum b/go.sum deleted file mode 100644 index 24dd27a..0000000 --- a/go.sum +++ /dev/null @@ -1,977 +0,0 @@ -4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= -4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= -4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= -4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= -codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= -codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= -codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= -dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= -dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= -github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= -github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= -github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= -github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= -github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= -github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= -github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= -github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= -github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= -github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= -github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= -github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= -github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= -github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= -github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= -github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= -github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= -github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= -github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= -github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= -github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= -github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= -github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= -github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= -github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= -github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= -github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= -github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= -github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= -github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= -github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= -github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= -github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= -github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= -github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= -github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= -github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= -github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= -github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= -github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= -github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= -github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= -github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= -github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= -github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= -github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= -github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= -github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= -github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= -github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= -github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= -github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= -github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= -github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= -github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= -github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= -github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= -github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= -github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= -github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= -github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= -github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= -github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= -github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= -github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= -github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= -github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= -github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= -github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= -github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= -github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= -github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= -github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= -github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= -github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= -github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= -github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= -github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= -github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= -github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= -github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= -github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= -github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= -github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= -github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= -github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= -github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= -github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= -github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.12.0 h1:fd61aD+XaAl+APBGWcbxzi+K0tb33JogvMG3ypJLtH8= -github.com/golangci/golangci-lint/v2 v2.12.0/go.mod h1:e/wBh0xvA13ag/OWByUmvjc9oYPtcKGpXycldJbc7t0= -github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= -github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= -github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= -github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= -github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= -github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= -github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= -github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= -github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= -github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= -github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= -github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= -github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= -github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= -github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= -github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= -github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= -github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= -github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= -github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= -github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= -github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= -github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= -github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= -github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= -github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= -github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= -github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= -github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= -github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= -github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= -github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= -github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= -github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= -github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= -github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= -github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= -github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= -github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= -github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= -github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= -github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= -github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= -github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= -github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= -github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= -github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= -github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= -github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= -github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= -github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= -github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= -github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= -github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= -github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= -github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= -github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= -github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= -github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= -github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= -github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= -github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= -github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= -github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= -github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= -github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= -github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= -github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= -github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= -github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= -github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= -github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= -github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= -github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= -github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= -github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= -github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= -github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= -github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= -github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= -github.com/ryancurrah/gomodguard/v2 v2.1.0 h1:iIIARHe7Fsp10LY5utfMmYA++hkVuKsMFGDzxnVcijU= -github.com/ryancurrah/gomodguard/v2 v2.1.0/go.mod h1:ryDqr6as4otkNbUp/U0m7zAsxGpwcJ9NtL6mvy9Zzdw= -github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= -github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= -github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= -github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= -github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= -github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= -github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= -github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= -github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= -github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= -github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= -github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= -github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= -github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= -github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= -github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= -github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= -github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= -github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= -github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= -github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= -github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= -github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= -github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= -github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= -github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= -github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= -github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= -github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= -github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= -github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= -github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= -github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= -github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= -github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= -github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= -github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= -github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= -github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= -gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= -go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= -go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= -go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= -go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= -go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= -go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= -go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= -go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= -go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= -go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= -mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go deleted file mode 100644 index 7432e78..0000000 --- a/internal/appconfig/config.go +++ /dev/null @@ -1,120 +0,0 @@ -package appconfig - -import ( - "errors" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/pelletier/go-toml/v2" -) - -const ( - ColorSchemeDark = "dark" - ColorSchemeLight = "light" - ColorSchemeSystem = "system" - - DiffStyleSplit = "split" - DiffStyleUnified = "unified" - - DiffThemePierre = "pierre" - DiffThemeGitHub = "github" - DiffThemeDarkPlus = "dark-plus" - DiffThemeLightPlus = "light-plus" - DiffThemeOneDarkPro = "one-dark-pro" - DiffThemeOneLight = "one-light" - DiffThemeMonokai = "monokai" - DiffThemeNightOwl = "night-owl" - DiffThemeTokyoNight = "tokyo-night" -) - -var ( - colorSchemes = []string{ - ColorSchemeDark, - ColorSchemeLight, - ColorSchemeSystem, - } - diffStyles = []string{ - DiffStyleSplit, - DiffStyleUnified, - } - diffThemes = []string{ - DiffThemePierre, - DiffThemeGitHub, - DiffThemeDarkPlus, - DiffThemeLightPlus, - DiffThemeOneDarkPro, - DiffThemeOneLight, - DiffThemeMonokai, - DiffThemeNightOwl, - DiffThemeTokyoNight, - } -) - -type Config struct { - UI UIConfig `toml:"ui"` -} - -type UIConfig struct { - ColorScheme string `toml:"color_scheme"` - DiffTheme string `toml:"diff_theme"` - DiffStyle string `toml:"diff_style"` - UIFontFamily string `toml:"ui_font_family"` - CodeFontFamily string `toml:"code_font_family"` - WordWrap *bool `toml:"word_wrap"` - LineNumbers *bool `toml:"line_numbers"` - LineBackgrounds *bool `toml:"line_backgrounds"` -} - -func DefaultPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "diffs", "config.toml"), nil -} - -func LoadDefault() (Config, error) { - path, err := DefaultPath() - if err != nil { - return Config{}, err - } - return Load(path) -} - -func Load(path string) (Config, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return Config{}, nil - } - if err != nil { - return Config{}, err - } - var cfg Config - if err := toml.Unmarshal(data, &cfg); err != nil { - return Config{}, err - } - return cfg, nil -} - -func NormalizeUIConfig(ui UIConfig) UIConfig { - ui.ColorScheme = strings.TrimSpace(ui.ColorScheme) - ui.DiffTheme = strings.TrimSpace(ui.DiffTheme) - ui.DiffStyle = strings.TrimSpace(ui.DiffStyle) - ui.UIFontFamily = strings.TrimSpace(ui.UIFontFamily) - ui.CodeFontFamily = strings.TrimSpace(ui.CodeFontFamily) - return ui -} - -func IsColorScheme(s string) bool { - return slices.Contains(colorSchemes, s) -} - -func IsDiffTheme(s string) bool { - return slices.Contains(diffThemes, s) -} - -func IsDiffStyle(s string) bool { - return slices.Contains(diffStyles, s) -} diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go deleted file mode 100644 index 0755c9e..0000000 --- a/internal/appconfig/config_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package appconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadMissingConfigReturnsEmptyConfig(t *testing.T) { - cfg, err := Load(filepath.Join(t.TempDir(), "missing.toml")) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.UI.ColorScheme != "" || cfg.UI.WordWrap != nil { - t.Fatalf("Load() = %+v, want empty config", cfg) - } -} - -func TestLoadConfig(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.toml") - if err := os.WriteFile(path, []byte(` -[ui] -color_scheme = "dark" -diff_theme = "github" -diff_style = "unified" -ui_font_family = '"Inter Variable", system-ui, sans-serif' -code_font_family = '"JetBrains Mono", ui-monospace, monospace' -word_wrap = true -line_numbers = false -line_backgrounds = true -`), 0o644); err != nil { - t.Fatal(err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.UI.ColorScheme != "dark" || cfg.UI.DiffTheme != "github" || cfg.UI.DiffStyle != "unified" { - t.Fatalf("unexpected string settings: %+v", cfg.UI) - } - if cfg.UI.UIFontFamily != `"Inter Variable", system-ui, sans-serif` { - t.Fatalf("ui_font_family = %q", cfg.UI.UIFontFamily) - } - if cfg.UI.CodeFontFamily != `"JetBrains Mono", ui-monospace, monospace` { - t.Fatalf("code_font_family = %q", cfg.UI.CodeFontFamily) - } - if cfg.UI.WordWrap == nil || !*cfg.UI.WordWrap { - t.Fatalf("word_wrap = %v, want true", cfg.UI.WordWrap) - } - if cfg.UI.LineNumbers == nil || *cfg.UI.LineNumbers { - t.Fatalf("line_numbers = %v, want false", cfg.UI.LineNumbers) - } - if cfg.UI.LineBackgrounds == nil || !*cfg.UI.LineBackgrounds { - t.Fatalf("line_backgrounds = %v, want true", cfg.UI.LineBackgrounds) - } -} - -func TestLoadInvalidConfigReturnsError(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.toml") - if err := os.WriteFile(path, []byte("[ui\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Fatal("Load() succeeded, want error") - } -} - -func TestNormalizeUIConfigTrimsStringSettings(t *testing.T) { - got := NormalizeUIConfig(UIConfig{ - ColorScheme: " dark ", - DiffTheme: " github ", - DiffStyle: " unified ", - UIFontFamily: " ui-sans-serif ", - CodeFontFamily: " ui-monospace ", - }) - if got.ColorScheme != ColorSchemeDark || got.DiffTheme != DiffThemeGitHub || got.DiffStyle != DiffStyleUnified { - t.Fatalf("NormalizeUIConfig() = %+v", got) - } - if got.UIFontFamily != "ui-sans-serif" || got.CodeFontFamily != "ui-monospace" { - t.Fatalf("NormalizeUIConfig() font families = %+v", got) - } -} - -func TestUIOptionValidation(t *testing.T) { - if !IsColorScheme(ColorSchemeSystem) || IsColorScheme("auto") { - t.Fatal("unexpected color scheme validation") - } - if !IsDiffTheme(DiffThemePierre) || IsDiffTheme("missing") { - t.Fatal("unexpected diff theme validation") - } - if !IsDiffStyle(DiffStyleSplit) || IsDiffStyle("side-by-side") { - t.Fatal("unexpected diff style validation") - } -} diff --git a/internal/comments/store.go b/internal/comments/store.go deleted file mode 100644 index 2d2444f..0000000 --- a/internal/comments/store.go +++ /dev/null @@ -1,402 +0,0 @@ -package comments - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - pathpkg "path" - "path/filepath" - "strings" - "sync" - "time" - - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -const ( - DefaultAuthor = "local" - DefaultSide = "additions" -) - -var ErrNotFound = errors.New("comment thread not found") - -type Store struct { - root string - path string - now func() time.Time - mu sync.Mutex -} - -type File struct { - Version int `json:"version"` - Repo string `json:"repo"` - Threads []Thread `json:"threads"` -} - -type Thread struct { - ID string `json:"id"` - Provider string `json:"provider"` - Branch string `json:"branch"` - Path string `json:"path"` - Side string `json:"side"` - Line int `json:"line"` - EndSide string `json:"endSide,omitempty"` - EndLine int `json:"endLine,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Comments []Comment `json:"comments"` - ReplyToID int64 `json:"replyToId,omitempty"` - URL string `json:"url,omitempty"` -} - -type Comment struct { - ID string `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` -} - -type AddThreadInput struct { - Path string `json:"path"` - Side string `json:"side"` - Line int `json:"line"` - EndSide string `json:"endSide"` - EndLine int `json:"endLine"` - Body string `json:"body"` - Author string `json:"author"` -} - -type AddReplyInput struct { - Body string `json:"body"` - Author string `json:"author"` -} - -func NewStore(cwd string) (*Store, error) { - ctx, cancel := context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) - defer cancel() - - root, err := gitcmd.Root(ctx, cwd) - if err != nil { - return nil, err - } - return &Store{ - root: root, - path: filepath.Join(root, ".diffs", "comments.json"), - now: time.Now, - }, nil -} - -func (s *Store) Path() string { - return s.path -} - -func (s *Store) Root() string { - return s.root -} - -func (s *Store) Branch(ctx context.Context) string { - if branch := gitcmd.Branch(ctx, s.root); branch != "" { - return branch - } - return "local" -} - -func (s *Store) List(ctx context.Context) ([]Thread, error) { - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return nil, err - } - branch := s.Branch(ctx) - threads := make([]Thread, 0, len(file.Threads)) - for _, thread := range file.Threads { - if thread.Branch == branch { - threads = append(threads, thread) - } - } - return threads, nil -} - -func (s *Store) AddThread(ctx context.Context, input AddThreadInput) (Thread, error) { - path, side, line, endSide, endLine, body, err := CleanThreadInput(input) - if err != nil { - return Thread{}, err - } - author := s.cleanAuthor(ctx, input.Author) - now := s.now().UTC() - - s.mu.Lock() - defer s.mu.Unlock() - - thread := Thread{ - ID: newID("thr"), - Provider: "local", - Branch: s.Branch(ctx), - Path: path, - Side: side, - Line: line, - Status: "open", - CreatedAt: now, - UpdatedAt: now, - Comments: []Comment{{ - ID: newID("cmt"), - Author: author, - Body: body, - CreatedAt: now, - }}, - } - if endLine != line || endSide != side { - thread.EndSide = endSide - thread.EndLine = endLine - } - - file, err := s.load() - if err != nil { - return Thread{}, err - } - file.Threads = append(file.Threads, thread) - if err := s.save(file); err != nil { - return Thread{}, err - } - return thread, nil -} - -func (s *Store) AddReply(ctx context.Context, threadID string, input AddReplyInput) (Thread, error) { - body := strings.TrimSpace(input.Body) - if body == "" { - return Thread{}, errors.New("body is required") - } - author := s.cleanAuthor(ctx, input.Author) - return s.updateThread(ctx, threadID, func(thread *Thread, now time.Time) error { - thread.Comments = append(thread.Comments, Comment{ - ID: newID("cmt"), - Author: author, - Body: body, - CreatedAt: now, - }) - thread.UpdatedAt = now - return nil - }) -} - -func (s *Store) Resolve(ctx context.Context, threadID string) (Thread, error) { - return s.setStatus(ctx, threadID, "resolved") -} - -func (s *Store) Reopen(ctx context.Context, threadID string) (Thread, error) { - return s.setStatus(ctx, threadID, "open") -} - -func (s *Store) Delete(ctx context.Context, threadID string) error { - threadID = strings.TrimSpace(threadID) - if threadID == "" { - return errors.New("thread id is required") - } - - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return err - } - branch := s.Branch(ctx) - for i := range file.Threads { - if file.Threads[i].ID != threadID || file.Threads[i].Branch != branch { - continue - } - file.Threads = append(file.Threads[:i], file.Threads[i+1:]...) - return s.save(file) - } - return ErrNotFound -} - -func (s *Store) setStatus(ctx context.Context, threadID, status string) (Thread, error) { - return s.updateThread(ctx, threadID, func(thread *Thread, now time.Time) error { - thread.Status = status - thread.UpdatedAt = now - return nil - }) -} - -func (s *Store) updateThread(ctx context.Context, threadID string, update func(*Thread, time.Time) error) (Thread, error) { - threadID = strings.TrimSpace(threadID) - if threadID == "" { - return Thread{}, errors.New("thread id is required") - } - - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return Thread{}, err - } - branch := s.Branch(ctx) - for i := range file.Threads { - if file.Threads[i].ID != threadID || file.Threads[i].Branch != branch { - continue - } - now := s.now().UTC() - if err := update(&file.Threads[i], now); err != nil { - return Thread{}, err - } - thread := file.Threads[i] - if err := s.save(file); err != nil { - return Thread{}, err - } - return thread, nil - } - return Thread{}, ErrNotFound -} - -func (s *Store) load() (File, error) { - data, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return File{Version: 1, Repo: s.root, Threads: []Thread{}}, nil - } - if err != nil { - return File{}, err - } - if len(strings.TrimSpace(string(data))) == 0 { - return File{Version: 1, Repo: s.root, Threads: []Thread{}}, nil - } - var file File - if err := json.Unmarshal(data, &file); err != nil { - return File{}, err - } - if file.Version == 0 { - file.Version = 1 - } - if file.Repo == "" { - file.Repo = s.root - } - if file.Threads == nil { - file.Threads = []Thread{} - } - return file, nil -} - -func (s *Store) save(file File) error { - file.Version = 1 - file.Repo = s.root - if file.Threads == nil { - file.Threads = []Thread{} - } - dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(file, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - - tmp, err := os.CreateTemp(dir, ".comments-*.json") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, s.path) -} - -// CleanThreadInput normalizes and validates a thread before it is stored or sent remotely. -func CleanThreadInput(input AddThreadInput) (string, string, int, string, int, string, error) { - path, side, line, endSide, endLine, body := input.Path, input.Side, input.Line, input.EndSide, input.EndLine, input.Body - path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") - side = strings.TrimSpace(side) - endSide = strings.TrimSpace(endSide) - body = strings.TrimSpace(body) - if path == "" { - return "", "", 0, "", 0, "", errors.New("path is required") - } - if hasParentPathSegment(path) { - return "", "", 0, "", 0, "", errors.New("path must be relative to the repository") - } - path = pathpkg.Clean(path) - if path == "." { - return "", "", 0, "", 0, "", errors.New("path is required") - } - if !filepath.IsLocal(path) { - return "", "", 0, "", 0, "", errors.New("path must be relative to the repository") - } - if line < 1 { - return "", "", 0, "", 0, "", errors.New("line must be greater than zero") - } - if endLine == 0 { - endLine = line - } - if endLine < 1 { - return "", "", 0, "", 0, "", errors.New("end line must be greater than zero") - } - if endLine < line { - return "", "", 0, "", 0, "", errors.New("end line must be greater than or equal to line") - } - if side == "" { - side = DefaultSide - } - if endSide == "" { - endSide = side - } - if side != "additions" && side != "deletions" { - return "", "", 0, "", 0, "", errors.New("side must be additions or deletions") - } - if endSide != "additions" && endSide != "deletions" { - return "", "", 0, "", 0, "", errors.New("end side must be additions or deletions") - } - if body == "" { - return "", "", 0, "", 0, "", errors.New("body is required") - } - return path, side, line, endSide, endLine, body, nil -} - -func hasParentPathSegment(path string) bool { - for _, part := range strings.Split(path, "/") { - if part == ".." { - return true - } - } - return false -} - -func (s *Store) cleanAuthor(ctx context.Context, author string) string { - author = strings.TrimSpace(author) - if author == "" { - return s.defaultAuthor(ctx) - } - return author -} - -func (s *Store) defaultAuthor(ctx context.Context) string { - name, err := gitcmd.Run(ctx, s.root, "config", "--get", "user.name") - if err == nil { - if author := strings.TrimSpace(string(name)); author != "" { - return author - } - } - return DefaultAuthor -} - -func newID(prefix string) string { - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) - } - return prefix + "_" + hex.EncodeToString(b[:]) -} diff --git a/internal/comments/store_test.go b/internal/comments/store_test.go deleted file mode 100644 index b7fa3d3..0000000 --- a/internal/comments/store_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package comments - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -func TestStoreAddReplyResolveAndReopen(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - store.now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) } - - thread, err := store.AddThread(context.Background(), AddThreadInput{ - Path: "web/src/App.tsx", - Line: 42, - EndLine: 45, - Side: "additions", - Body: "Check this", - }) - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - if thread.ID == "" || thread.Provider != "local" || thread.Status != "open" || thread.Branch != "main" { - t.Fatalf("unexpected thread metadata: %+v", thread) - } - if thread.Line != 42 || thread.EndLine != 45 || thread.Side != "additions" || thread.EndSide != "additions" { - t.Fatalf("unexpected thread range: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Body != "Check this" || thread.Comments[0].Author != "Test" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } - - thread, err = store.AddReply(context.Background(), thread.ID, AddReplyInput{Body: "Reply", Author: "agent"}) - if err != nil { - t.Fatalf("AddReply() error = %v", err) - } - if len(thread.Comments) != 2 || thread.Comments[1].Body != "Reply" || thread.Comments[1].Author != "agent" { - t.Fatalf("reply was not appended: %+v", thread.Comments) - } - - thread, err = store.Resolve(context.Background(), thread.ID) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - if thread.Status != "resolved" { - t.Fatalf("status = %q, want resolved", thread.Status) - } - - thread, err = store.Reopen(context.Background(), thread.ID) - if err != nil { - t.Fatalf("Reopen() error = %v", err) - } - if thread.Status != "open" { - t.Fatalf("status = %q, want open", thread.Status) - } - - if err := store.Delete(context.Background(), thread.ID); err != nil { - t.Fatalf("Delete() error = %v", err) - } - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != 0 { - t.Fatalf("threads = %+v, want deleted thread removed", threads) - } -} - -func TestStoreFallsBackToLocalAuthorWithoutGitConfig(t *testing.T) { - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - t.Setenv("HOME", t.TempDir()) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - thread, err := store.AddThread(context.Background(), AddThreadInput{ - Path: "web/src/App.tsx", - Line: 42, - Body: "Check this", - }) - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != DefaultAuthor { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } -} - -func TestStoreListsCurrentBranchOnly(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - if _, err := store.AddThread(context.Background(), AddThreadInput{Path: "a.go", Line: 1, Body: "main"}); err != nil { - t.Fatal(err) - } - - git(t, dir, "checkout", "-b", "feature/comments") - if _, err := store.AddThread(context.Background(), AddThreadInput{Path: "b.go", Line: 1, Body: "feature"}); err != nil { - t.Fatal(err) - } - - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != 1 || threads[0].Path != "b.go" { - t.Fatalf("threads = %+v, want only feature branch thread", threads) - } -} - -func TestStoreKeepsConcurrentAdds(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - - const count = 20 - errs := make(chan error, count) - var wg sync.WaitGroup - for i := range count { - wg.Add(1) - go func(i int) { - defer wg.Done() - _, err := store.AddThread(context.Background(), AddThreadInput{ - Path: fmt.Sprintf("file-%02d.go", i), - Line: 1, - Body: "body", - }) - errs <- err - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - } - - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != count { - t.Fatalf("thread count = %d, want %d", len(threads), count) - } -} - -func TestStoreReturnsNotFoundForOtherBranch(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - thread, err := store.AddThread(context.Background(), AddThreadInput{Path: "a.go", Line: 1, Body: "main"}) - if err != nil { - t.Fatal(err) - } - - git(t, dir, "checkout", "-b", "feature/comments") - _, err = store.Resolve(context.Background(), thread.ID) - if !errors.Is(err, ErrNotFound) { - t.Fatalf("Resolve() error = %v, want ErrNotFound", err) - } -} - -func TestStoreRejectsInvalidThreadInput(t *testing.T) { - for _, input := range []AddThreadInput{ - {Path: "", Line: 1, Body: "body"}, - {Path: "../outside", Line: 1, Body: "body"}, - {Path: "a/../b", Line: 1, Body: "body"}, - {Path: "a/../../outside", Line: 1, Body: "body"}, - {Path: `a\..\b`, Line: 1, Body: "body"}, - {Path: `a\..\..\outside`, Line: 1, Body: "body"}, - {Path: "a.go", Line: 0, Body: "body"}, - {Path: "a.go", Line: 1, EndLine: -1, Body: "body"}, - {Path: "a.go", Line: 10, EndLine: 1, Body: "body"}, - {Path: "a.go", Line: 1, Side: "right", Body: "body"}, - {Path: "a.go", Line: 1, EndSide: "right", Body: "body"}, - {Path: "a.go", Line: 1, Body: ""}, - } { - t.Run(input.Path, func(t *testing.T) { - if _, _, _, _, _, _, err := CleanThreadInput(input); err == nil { - t.Fatalf("CleanThreadInput(%+v) succeeded, want error", input) - } - }) - } -} - -func newGitRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - git(t, dir, "config", "user.email", "test@example.com") - git(t, dir, "config", "user.name", "Test") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("test\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "README.md") - git(t, dir, "commit", "-m", "init") - return dir -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 3396d40..0000000 --- a/internal/git/git.go +++ /dev/null @@ -1,57 +0,0 @@ -package git - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -const DefaultTimeout = 2 * time.Second - -var ErrNotRepository = errors.New("not a git repository") - -func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") - return cmd -} - -func Run(ctx context.Context, dir string, args ...string) ([]byte, error) { - return Command(ctx, dir, args...).Output() -} - -func OK(ctx context.Context, dir string, args ...string) bool { - return Command(ctx, dir, args...).Run() == nil -} - -func Root(ctx context.Context, cwd string) (string, error) { - if cwd == "" { - cwd = "." - } - abs, err := filepath.Abs(cwd) - if err != nil { - return "", err - } - root, err := Run(ctx, abs, "rev-parse", "--show-toplevel") - if err != nil { - return "", ErrNotRepository - } - return strings.TrimSpace(string(root)), nil -} - -func Branch(ctx context.Context, dir string) string { - branch, err := Run(ctx, dir, "branch", "--show-current") - if err == nil && strings.TrimSpace(string(branch)) != "" { - return strings.TrimSpace(string(branch)) - } - commit, err := Run(ctx, dir, "rev-parse", "--short", "HEAD") - if err == nil { - return strings.TrimSpace(string(commit)) - } - return "" -} diff --git a/internal/server/github_comments.go b/internal/server/github_comments.go deleted file mode 100644 index c53453b..0000000 --- a/internal/server/github_comments.go +++ /dev/null @@ -1,504 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os/exec" - "strconv" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/comments" -) - -const githubCommentsTimeout = 30 * time.Second - -var runGH = defaultRunGH - -type githubReviewThreadsResponse struct { - Data struct { - Repository struct { - PullRequest struct { - ReviewThreads struct { - Nodes []githubReviewThread `json:"nodes"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - } `json:"reviewThreads"` - } `json:"pullRequest"` - } `json:"repository"` - } `json:"data"` -} - -type githubReviewThread struct { - ID string `json:"id"` - IsResolved bool `json:"isResolved"` - Path string `json:"path"` - Line int `json:"line"` - DiffSide string `json:"diffSide"` - StartLine int `json:"startLine"` - StartDiffSide string `json:"startDiffSide"` - Comments struct { - Nodes []githubReviewComment `json:"nodes"` - } `json:"comments"` -} - -type githubReviewComment struct { - ID string `json:"id"` - DatabaseID int64 `json:"databaseId"` - Author *ghAuthor `json:"author"` - Body string `json:"body"` - URL string `json:"url"` - CreatedAt time.Time `json:"createdAt"` -} - -type ghAuthor struct { - Login string `json:"login"` -} - -type githubPullResponse struct { - Title string `json:"title"` - State string `json:"state"` - Draft bool `json:"draft"` - Merged bool `json:"merged"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Changed int `json:"changed_files"` - Commits int `json:"commits"` - User *ghAuthor `json:"user"` - Head struct { - SHA string `json:"sha"` - Ref string `json:"ref"` - Label string `json:"label"` - Repo struct { - FullName string `json:"full_name"` - } `json:"repo"` - } `json:"head"` - Base struct { - Ref string `json:"ref"` - Label string `json:"label"` - Repo struct { - FullName string `json:"full_name"` - } `json:"repo"` - } `json:"base"` -} - -type pullRequestInfoResponse struct { - Title string `json:"title"` - State string `json:"state"` - Draft bool `json:"draft"` - Merged bool `json:"merged"` - Author string `json:"author"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - ChangedFiles int `json:"changedFiles"` - Commits int `json:"commits"` - HeadRef string `json:"headRef"` - HeadLabel string `json:"headLabel"` - HeadRepo string `json:"headRepo"` - BaseRef string `json:"baseRef"` - BaseLabel string `json:"baseLabel"` - BaseRepo string `json:"baseRepo"` -} - -type githubCreatedComment struct { - ID int64 `json:"id"` - NodeID string `json:"node_id"` -} - -func (s *Server) listPullRequestComments(ctx context.Context, org, repo, number string) ([]comments.Thread, error) { - ctx, cancel := context.WithTimeout(ctx, githubCommentsTimeout) - defer cancel() - - var threads []comments.Thread - cursor := "" - for { - args := []string{ - "api", - "graphql", - "--hostname", - s.githubHost, - "-f", - "query=" + reviewThreadsQuery, - "-F", - "owner=" + org, - "-F", - "name=" + repo, - "-F", - "number=" + number, - } - if cursor != "" { - args = append(args, "-F", "cursor="+cursor) - } - out, err := ghOutput(ctx, "gh api graphql", args...) - if err != nil { - return nil, err - } - var response githubReviewThreadsResponse - if err := json.Unmarshal(out, &response); err != nil { - return nil, err - } - page := response.Data.Repository.PullRequest.ReviewThreads - for _, thread := range page.Nodes { - if converted, ok := convertGitHubThread(thread); ok { - threads = append(threads, converted) - } - } - if !page.PageInfo.HasNextPage { - return threads, nil - } - cursor = page.PageInfo.EndCursor - if cursor == "" { - return threads, nil - } - } -} - -func (s *Server) addPullRequestComment(ctx context.Context, org, repo, number string, input comments.AddThreadInput) (comments.Thread, error) { - path, side, line, endSide, endLine, body, err := comments.CleanThreadInput(input) - if err != nil { - return comments.Thread{}, err - } - sha, err := s.pullRequestHeadSHA(ctx, org, repo, number) - if err != nil { - return comments.Thread{}, err - } - - args := []string{ - "api", - "-X", - "POST", - fmt.Sprintf("repos/%s/%s/pulls/%s/comments", org, repo, number), - "--hostname", - s.githubHost, - "--raw-field", - "body=" + body, - "--raw-field", - "commit_id=" + sha, - "--raw-field", - "path=" + path, - "--raw-field", - "side=" + githubSide(endSide), - "--field", - "line=" + strconv.Itoa(endLine), - } - if endLine != line || endSide != side { - args = append(args, - "--field", - "start_line="+strconv.Itoa(line), - "--raw-field", - "start_side="+githubSide(side), - ) - } - out, err := ghOutput(ctx, "gh api create pull request comment", args...) - if err != nil { - return comments.Thread{}, err - } - var created githubCreatedComment - if err := json.Unmarshal(out, &created); err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - for _, comment := range thread.Comments { - if comment.ID == created.NodeID || comment.ID == strconv.FormatInt(created.ID, 10) { - return true - } - } - return false - }) -} - -func (s *Server) addPullRequestReply(ctx context.Context, org, repo, number, threadID string, input comments.AddReplyInput) (comments.Thread, error) { - body := strings.TrimSpace(input.Body) - if body == "" { - return comments.Thread{}, errors.New("body is required") - } - thread, err := s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - return thread.ID == threadID - }) - if err != nil { - return comments.Thread{}, err - } - if thread.ReplyToID == 0 { - return comments.Thread{}, errors.New("pull request thread has no reply target") - } - _, err = ghOutput(ctx, "gh api create pull request comment reply", - "api", - "-X", - "POST", - fmt.Sprintf("repos/%s/%s/pulls/%s/comments/%d/replies", org, repo, number, thread.ReplyToID), - "--hostname", - s.githubHost, - "--raw-field", - "body="+body, - ) - if err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(next comments.Thread) bool { - return next.ID == threadID - }) -} - -func (s *Server) setPullRequestThreadResolved(ctx context.Context, org, repo, number, threadID string, resolved bool) (comments.Thread, error) { - mutation := resolveReviewThreadMutation - label := "gh api resolve review thread" - if !resolved { - mutation = unresolveReviewThreadMutation - label = "gh api unresolve review thread" - } - _, err := ghOutput(ctx, label, - "api", - "graphql", - "--hostname", - s.githubHost, - "-f", - "query="+mutation, - "-F", - "threadID="+threadID, - ) - if err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - return thread.ID == threadID - }) -} - -func (s *Server) findPullRequestThread(ctx context.Context, org, repo, number string, match func(comments.Thread) bool) (comments.Thread, error) { - threads, err := s.listPullRequestComments(ctx, org, repo, number) - if err != nil { - return comments.Thread{}, err - } - for _, thread := range threads { - if match(thread) { - return thread, nil - } - } - return comments.Thread{}, comments.ErrNotFound -} - -func (s *Server) pullRequestHeadSHA(ctx context.Context, org, repo, number string) (string, error) { - response, err := s.pullRequest(ctx, org, repo, number) - if err != nil { - return "", err - } - if response.Head.SHA == "" { - return "", errors.New("pull request head sha is missing") - } - return response.Head.SHA, nil -} - -func (s *Server) pullRequestInfo(ctx context.Context, org, repo, number string) (pullRequestInfoResponse, error) { - response, err := s.pullRequest(ctx, org, repo, number) - if err != nil { - return pullRequestInfoResponse{}, err - } - return pullRequestInfoResponse{ - Title: response.Title, - State: response.State, - Draft: response.Draft, - Merged: response.Merged, - Author: commentAuthor(githubReviewComment{Author: response.User}), - CreatedAt: response.CreatedAt, - UpdatedAt: response.UpdatedAt, - Additions: response.Additions, - Deletions: response.Deletions, - ChangedFiles: response.Changed, - Commits: response.Commits, - HeadRef: response.Head.Ref, - HeadLabel: response.Head.Label, - HeadRepo: response.Head.Repo.FullName, - BaseRef: response.Base.Ref, - BaseLabel: response.Base.Label, - BaseRepo: response.Base.Repo.FullName, - }, nil -} - -func (s *Server) pullRequest(ctx context.Context, org, repo, number string) (githubPullResponse, error) { - out, err := ghOutput(ctx, "gh api pull request", - "api", - fmt.Sprintf("repos/%s/%s/pulls/%s", org, repo, number), - "--hostname", - s.githubHost, - ) - if err != nil { - return githubPullResponse{}, err - } - var response githubPullResponse - if err := json.Unmarshal(out, &response); err != nil { - return githubPullResponse{}, err - } - return response, nil -} - -func convertGitHubThread(thread githubReviewThread) (comments.Thread, bool) { - if thread.ID == "" || len(thread.Comments.Nodes) == 0 { - return comments.Thread{}, false - } - first := thread.Comments.Nodes[0] - last := thread.Comments.Nodes[len(thread.Comments.Nodes)-1] - path := thread.Path - line := thread.Line - if thread.StartLine > 0 { - line = thread.StartLine - } - if path == "" || line < 1 { - return comments.Thread{}, false - } - side := commentSide(thread.StartDiffSide) - if side == "" { - side = commentSide(thread.DiffSide) - } - if side == "" { - side = comments.DefaultSide - } - endLine := thread.Line - if endLine == 0 { - endLine = line - } - endSide := commentSide(thread.DiffSide) - if endSide == "" { - endSide = side - } - - status := "open" - if thread.IsResolved { - status = "resolved" - } - converted := comments.Thread{ - ID: thread.ID, - Provider: "github", - Path: path, - Side: side, - Line: line, - Status: status, - CreatedAt: first.CreatedAt, - UpdatedAt: last.CreatedAt, - ReplyToID: first.DatabaseID, - URL: first.URL, - Comments: make([]comments.Comment, 0, len(thread.Comments.Nodes)), - } - if endLine != line || endSide != side { - converted.EndLine = endLine - converted.EndSide = endSide - } - for _, comment := range thread.Comments.Nodes { - converted.Comments = append(converted.Comments, comments.Comment{ - ID: commentID(comment), - Author: commentAuthor(comment), - Body: comment.Body, - CreatedAt: comment.CreatedAt, - }) - } - return converted, true -} - -func commentID(comment githubReviewComment) string { - if comment.ID != "" { - return comment.ID - } - if comment.DatabaseID != 0 { - return strconv.FormatInt(comment.DatabaseID, 10) - } - return "" -} - -func commentAuthor(comment githubReviewComment) string { - if comment.Author != nil && comment.Author.Login != "" { - return comment.Author.Login - } - return "github" -} - -func commentSide(side string) string { - switch side { - case "RIGHT": - return "additions" - case "LEFT": - return "deletions" - default: - return "" - } -} - -func githubSide(side string) string { - if side == "deletions" { - return "LEFT" - } - return "RIGHT" -} - -func defaultRunGH(ctx context.Context, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, "gh", args...).Output() -} - -func ghOutput(ctx context.Context, label string, args ...string) ([]byte, error) { - out, err := runGH(ctx, args...) - if err != nil { - return nil, commandError(label, err, nil, "") - } - return out, nil -} - -const reviewThreadsQuery = ` -query($owner: String!, $name: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - isResolved - path - line - diffSide - startLine - startDiffSide - comments(first: 100) { - nodes { - id - databaseId - author { - login - } - body - url - createdAt - } - } - } - } - } - } -}` - -const resolveReviewThreadMutation = ` -mutation($threadID: ID!) { - resolveReviewThread(input: {threadId: $threadID}) { - thread { - id - isResolved - } - } -}` - -const unresolveReviewThreadMutation = ` -mutation($threadID: ID!) { - unresolveReviewThread(input: {threadId: $threadID}) { - thread { - id - isResolved - } - } -}` diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index bf037c6..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,838 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os/exec" - "path" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/appconfig" - "github.com/imfing/diffs-cli/internal/comments" - gitcmd "github.com/imfing/diffs-cli/internal/git" - "github.com/imfing/diffs-cli/internal/webassets" -) - -const ( - DefaultGitHubHost = "github.com" - gitDevNull = "/dev/null" - // The PR UI should match GitHub's final Files changed diff, not the per-commit patch stream. - githubDiffMedia = "application/vnd.github.v3.diff" - // Bounds the gh/git calls behind the lazy repo-context lookup. - repoContextTimeout = 8 * time.Second -) - -type Config struct { - CWD string - GitHubHost string - OnChange func([]ChangedFile) - UI appconfig.UIConfig - Watch bool -} - -type Server struct { - cwd string - githubHost string - staticFS fs.FS - ui appconfig.UIConfig - comments *comments.Store - events *changeBroadcaster - watcher *localWatcher -} - -type configResponse struct { - CWD string `json:"cwd"` - GitBranch string `json:"gitBranch"` - GitHubHost string `json:"githubHost"` - ColorScheme string `json:"colorScheme,omitempty"` - DiffTheme string `json:"diffTheme,omitempty"` - DiffStyle string `json:"diffStyle,omitempty"` - UIFontFamily string `json:"uiFontFamily,omitempty"` - CodeFontFamily string `json:"codeFontFamily,omitempty"` - WordWrap *bool `json:"wordWrap,omitempty"` - LineNumbers *bool `json:"lineNumbers,omitempty"` - LineBackgrounds *bool `json:"lineBackgrounds,omitempty"` -} - -type gitCommandSpec struct { - label string - args []string -} - -type commentTarget struct { - local bool - org string - repo string - number string -} - -func New(cfg Config) (http.Handler, error) { - cwd := cfg.CWD - if cwd == "" { - cwd = "." - } - absCWD, err := filepath.Abs(cwd) - if err != nil { - return nil, err - } - host := strings.TrimSpace(cfg.GitHubHost) - if host == "" { - host = DefaultGitHubHost - } - ui := appconfig.NormalizeUIConfig(cfg.UI) - staticFS, err := webassets.DistFS() - if err != nil { - return nil, err - } - commentStore, commentErr := comments.NewStore(absCWD) - if cfg.Watch && commentErr != nil { - return nil, commentErr - } - events := newChangeBroadcaster() - notifyChange := func(paths []string) { - gitStateChanged := hasGitStateEvent(paths) - status, err := gitStatus(absCWD) - var changed []ChangedFile - if err == nil { - changed = changedFilesForEvents(paths, status) - } else { - changed = changedFilesFromEvents(paths) - } - if len(changed) == 0 { - if gitStateChanged { - events.broadcast() - if cfg.OnChange != nil { - cfg.OnChange(nil) - } - } - return - } - events.broadcast() - if cfg.OnChange != nil { - cfg.OnChange(changed) - } - } - var watcher *localWatcher - if cfg.Watch { - watcher, err = newLocalWatcher(absCWD, notifyChange) - if err != nil { - return nil, err - } - } - s := &Server{ - cwd: absCWD, - githubHost: host, - staticFS: staticFS, - ui: ui, - comments: commentStore, - events: events, - watcher: watcher, - } - - mux := http.NewServeMux() - mux.HandleFunc("GET /api/config", s.handleConfig) - mux.HandleFunc("GET /api/events", s.handleEvents) - mux.HandleFunc("GET /api/local-diff", s.handleLocalDiff) - mux.HandleFunc("GET /api/branch-diff", s.handleBranchDiff) - mux.HandleFunc("GET /api/repo-context", s.handleRepoContext) - mux.HandleFunc("GET /api/comments", s.handleListComments) - mux.HandleFunc("POST /api/comments", s.handleAddComment) - mux.HandleFunc("DELETE /api/comments/{threadID}", s.handleDeleteComment) - mux.HandleFunc("POST /api/comments/{threadID}/replies", s.handleReplyComment) - mux.HandleFunc("POST /api/comments/{threadID}/resolve", s.handleResolveComment) - mux.HandleFunc("POST /api/comments/{threadID}/reopen", s.handleReopenComment) - mux.HandleFunc("GET /api/pull/{org}/{repo}/{number}", s.handlePullRequestInfo) - mux.HandleFunc("GET /api/patch/{org}/{repo}/{number}", s.handlePatch) - mux.HandleFunc("/", s.handleStatic) - return mux, nil -} - -func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - config := configResponse{ - CWD: s.cwd, - GitBranch: s.gitBranch(r.Context()), - GitHubHost: s.githubHost, - } - if appconfig.IsColorScheme(s.ui.ColorScheme) { - config.ColorScheme = s.ui.ColorScheme - } - if appconfig.IsDiffTheme(s.ui.DiffTheme) { - config.DiffTheme = s.ui.DiffTheme - } - if appconfig.IsDiffStyle(s.ui.DiffStyle) { - config.DiffStyle = s.ui.DiffStyle - } - if s.ui.UIFontFamily != "" { - config.UIFontFamily = s.ui.UIFontFamily - } - if s.ui.CodeFontFamily != "" { - config.CodeFontFamily = s.ui.CodeFontFamily - } - if s.ui.WordWrap != nil { - config.WordWrap = s.ui.WordWrap - } - if s.ui.LineNumbers != nil { - config.LineNumbers = s.ui.LineNumbers - } - if s.ui.LineBackgrounds != nil { - config.LineBackgrounds = s.ui.LineBackgrounds - } - writeJSON(w, http.StatusOK, config) -} - -func (s *Server) handleLocalDiff(w http.ResponseWriter, r *http.Request) { - patch, err := s.localDiff(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -func (s *Server) handleBranchDiff(w http.ResponseWriter, r *http.Request) { - base := strings.TrimSpace(r.URL.Query().Get("base")) - if base == "" { - writeError(w, http.StatusBadRequest, errors.New("base query parameter is required")) - return - } - if !isSafeRefArg(base) { - writeError(w, http.StatusBadRequest, fmt.Errorf("invalid base ref: %q", base)) - return - } - patch, err := s.branchDiff(r.Context(), base, branchDirtyEnabled(r.URL.Query().Get("dirty"))) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -// repoContextResponse carries the optional GitHub/branch links the toolbar -// menu and local empty state surface "when applicable". Every field is -// best-effort: an empty value means "not available", so the UI just hides that -// action rather than showing an error. -type repoContextResponse struct { - // Canonical GitHub URL of the repo (e.g. https://github.com/org/repo). - RepoURL string `json:"repoUrl,omitempty"` - // GitHub URL of the pull request open for the current branch, if any. - PRURL string `json:"prUrl,omitempty"` - // Inferred base ref for `diffs branch`-style diffing (PR base -> repo - // default -> main/master), validated against the local repo. - BranchBase string `json:"branchBase,omitempty"` -} - -// handleRepoContext resolves GitHub repo/PR links and a branch base for the -// local repository, so the toolbar can offer context-aware actions without the -// user knowing the URLs or base ref. Fetched lazily by the client (on menu open -// / empty state) so the gh/git lookups never slow the normal page load. One gh -// call per resource (PR, repo) — each requests every field it needs at once. -func (s *Server) handleRepoContext(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), repoContextTimeout) - defer cancel() - - var pr struct { - URL string `json:"url"` - BaseRefName string `json:"baseRefName"` - } - _ = json.Unmarshal(s.ghJSON(ctx, "pr", "view", "--json", "url,baseRefName"), &pr) - - var repo struct { - URL string `json:"url"` - DefaultBranchRef struct { - Name string `json:"name"` - } `json:"defaultBranchRef"` - } - _ = json.Unmarshal(s.ghJSON(ctx, "repo", "view", "--json", "url,defaultBranchRef"), &repo) - - writeJSON(w, http.StatusOK, repoContextResponse{ - RepoURL: repo.URL, - PRURL: pr.URL, - BranchBase: s.resolveBranchBase(ctx, pr.BaseRefName, repo.DefaultBranchRef.Name), - }) -} - -// resolveBranchBase mirrors the `diffs branch` CLI inference: the first of the -// PR base, repo default, then main/master that resolves to a commit locally -// (or as origin/). Returns "" when none do. The PR/default refs are passed -// in (already fetched by the caller) so this stays pure git and easy to test. -func (s *Server) resolveBranchBase(ctx context.Context, prBase, repoDefault string) string { - for _, candidate := range []string{prBase, repoDefault, "main", "master"} { - if candidate == "" { - continue - } - if ref, ok := s.resolveLocalRef(ctx, candidate); ok { - return ref - } - } - return "" -} - -// resolveLocalRef mirrors the CLI helper: a ref counts only if it resolves to a -// commit locally, falling back to origin/ for inferred bases that exist -// only as a remote-tracking ref in fresh clones. -func (s *Server) resolveLocalRef(ctx context.Context, ref string) (string, bool) { - if s.gitRefExists(ctx, ref) { - return ref, true - } - if candidate := "origin/" + ref; s.gitRefExists(ctx, candidate) { - return candidate, true - } - return "", false -} - -func (s *Server) gitRefExists(ctx context.Context, ref string) bool { - return s.gitOK(ctx, "rev-parse", "--verify", "--quiet", ref+"^{commit}") -} - -// ghJSON runs gh in the repository directory and returns its raw stdout, or nil -// on any failure (gh absent, no PR, not a GitHub remote, timeout). Callers -// json.Unmarshal the result, tolerating nil as an empty object. -func (s *Server) ghJSON(ctx context.Context, args ...string) []byte { - cmd := exec.CommandContext(ctx, "gh", args...) - cmd.Dir = s.cwd - out, err := cmd.Output() - if err != nil { - return nil - } - return out -} - -func (s *Server) handlePullRequestInfo(w http.ResponseWriter, r *http.Request) { - org, repo, number, ok := prPathValues(w, r) - if !ok { - return - } - info, err := s.pullRequestInfo(r.Context(), org, repo, number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, info) -} - -func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - threads, err := s.listPullRequestComments(r.Context(), target.org, target.repo, target.number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": threads}) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - threads, err := store.List(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": threads}) -} - -func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - var input comments.AddThreadInput - if err := readJSON(r, &input); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - if !target.local { - thread, err := s.addPullRequestComment(r.Context(), target.org, target.repo, target.number, input) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusCreated, thread) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - thread, err := store.AddThread(r.Context(), input) - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - writeJSON(w, http.StatusCreated, thread) -} - -func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - writeError(w, http.StatusBadRequest, errors.New("deleting GitHub comments is not supported")) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - err := store.Delete(r.Context(), r.PathValue("threadID")) - if errors.Is(err, comments.ErrNotFound) { - writeError(w, http.StatusNotFound, err) - return - } - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) handleReplyComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - var input comments.AddReplyInput - if err := readJSON(r, &input); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - if !target.local { - thread, err := s.addPullRequestReply(r.Context(), target.org, target.repo, target.number, r.PathValue("threadID"), input) - writeThreadOrError(w, thread, err) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - thread, err := store.AddReply(r.Context(), r.PathValue("threadID"), input) - writeThreadOrError(w, thread, err) -} - -func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) { - s.handleSetResolved(w, r, true) -} - -func (s *Server) handleReopenComment(w http.ResponseWriter, r *http.Request) { - s.handleSetResolved(w, r, false) -} - -func (s *Server) handleSetResolved(w http.ResponseWriter, r *http.Request, resolved bool) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - thread, err := s.setPullRequestThreadResolved(r.Context(), target.org, target.repo, target.number, r.PathValue("threadID"), resolved) - writeThreadOrError(w, thread, err) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - var ( - thread comments.Thread - err error - ) - if resolved { - thread, err = store.Resolve(r.Context(), r.PathValue("threadID")) - } else { - thread, err = store.Reopen(r.Context(), r.PathValue("threadID")) - } - writeThreadOrError(w, thread, err) -} - -func (s *Server) requireComments(w http.ResponseWriter) (*comments.Store, bool) { - if s.comments == nil { - writeError(w, http.StatusServiceUnavailable, errors.New("local comments require a git repository")) - return nil, false - } - return s.comments, true -} - -func writeThreadOrError(w http.ResponseWriter, thread comments.Thread, err error) { - if errors.Is(err, comments.ErrNotFound) { - writeError(w, http.StatusNotFound, err) - return - } - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - writeJSON(w, http.StatusOK, thread) -} - -func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - writeError(w, http.StatusInternalServerError, errors.New("streaming is not supported")) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - events := s.events.subscribe(r.Context()) - _, _ = io.WriteString(w, ": connected\n\n") - flusher.Flush() - - ping := time.NewTicker(25 * time.Second) - defer ping.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-events: - _, _ = io.WriteString(w, "event: diff\ndata: {}\n\n") - flusher.Flush() - case <-ping.C: - _, _ = io.WriteString(w, ": ping\n\n") - flusher.Flush() - } - } -} - -func prPathValues(w http.ResponseWriter, r *http.Request) (string, string, string, bool) { - org := r.PathValue("org") - repo := r.PathValue("repo") - number := r.PathValue("number") - if !safePathPart(org) || !safePathPart(repo) || !pullNumber.MatchString(number) { - writeError(w, http.StatusBadRequest, errors.New("invalid pull request path")) - return "", "", "", false - } - return org, repo, number, true -} - -func (s *Server) commentTarget(w http.ResponseWriter, r *http.Request) (commentTarget, bool) { - query := r.URL.Query() - org := query.Get("org") - repo := query.Get("repo") - number := query.Get("number") - if org == "" && repo == "" && number == "" { - return commentTarget{local: true}, true - } - if !safePathPart(org) || !safePathPart(repo) || !pullNumber.MatchString(number) { - writeError(w, http.StatusBadRequest, errors.New("invalid pull request path")) - return commentTarget{}, false - } - return commentTarget{org: org, repo: repo, number: number}, true -} - -func (s *Server) handlePatch(w http.ResponseWriter, r *http.Request) { - org, repo, number, ok := prPathValues(w, r) - if !ok { - return - } - patch, err := s.pullRequestPatch(r.Context(), org, repo, number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - serveIndex(w, r, s.staticFS) - return - } - - cleanPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") - if cleanPath == "." { - serveIndex(w, r, s.staticFS) - return - } - if _, err := fs.Stat(s.staticFS, cleanPath); err == nil { - http.FileServerFS(s.staticFS).ServeHTTP(w, r) - return - } - serveIndex(w, r, s.staticFS) -} - -func (s *Server) localDiff(ctx context.Context) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - hasHead := s.gitOK(ctx, "rev-parse", "--verify", "HEAD") - var patch strings.Builder - - commands := []gitCommandSpec{} - if hasHead { - commands = append(commands, gitCommandSpec{ - label: "git diff", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "HEAD", "--"}, - }) - } else { - commands = append(commands, - gitCommandSpec{ - label: "git diff --cached", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "--cached", "--"}, - }, - gitCommandSpec{ - label: "git diff", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "--"}, - }, - ) - } - for _, command := range commands { - out, err := s.gitOutput(ctx, command.label, command.args...) - if err != nil { - return "", err - } - appendPatch(&patch, out) - } - - untracked, err := s.untrackedPatch(ctx) - if err != nil { - return "", err - } - appendPatch(&patch, untracked) - - return patch.String(), nil -} - -func (s *Server) branchDiff(ctx context.Context, base string, includeDirty bool) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if includeDirty { - return s.branchDiffWithDirty(ctx, base) - } - - out, err := s.gitOutput(ctx, "git diff", - "diff", "--no-ext-diff", "--patch", "--submodule=diff", base+"...HEAD", "--") - if err != nil { - return "", err - } - return out, nil -} - -func (s *Server) branchDiffWithDirty(ctx context.Context, base string) (string, error) { - mergeBase, err := s.gitOutput(ctx, "git merge-base", "merge-base", base, "HEAD") - if err != nil { - return "", err - } - mergeBase = strings.TrimSpace(mergeBase) - if mergeBase == "" { - return "", errors.New("git merge-base returned an empty ref") - } - - // Compare merge base directly to the working tree so dirty edits replace, - // rather than duplicate, committed branch hunks for the same file. - out, err := s.gitOutput(ctx, "git diff", - "diff", "--no-ext-diff", "--patch", "--submodule=diff", mergeBase, "--") - if err != nil { - return "", err - } - - var patch strings.Builder - appendPatch(&patch, out) - untracked, err := s.untrackedPatch(ctx) - if err != nil { - return "", err - } - appendPatch(&patch, untracked) - return patch.String(), nil -} - -func branchDirtyEnabled(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -// isSafeRefArg rejects revision expressions and strings git could misinterpret -// as flags. Branch mode accepts branch-like refs, not arbitrary revspecs. -func isSafeRefArg(ref string) bool { - if ref == "" || - strings.HasPrefix(ref, "-") || - strings.Contains(ref, "..") || - strings.Contains(ref, "~") || - strings.Contains(ref, "^") || - ref == "@" || - strings.Contains(ref, "{") || - strings.Contains(ref, "}") || - strings.Contains(ref, "\\") { - return false - } - for _, r := range ref { - if r <= ' ' || r == 0x7f || r == ':' || r == '?' || r == '*' || r == '[' { - return false - } - } - return true -} - -func (s *Server) pullRequestPatch(ctx context.Context, org, repo, number string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 90*time.Second) - defer cancel() - - endpoint := fmt.Sprintf("repos/%s/%s/pulls/%s", org, repo, number) - args := []string{ - "api", - endpoint, - "--hostname", - s.githubHost, - "-H", - "Accept: " + githubDiffMedia, - } - out, err := ghOutput(ctx, "gh api", args...) - if err != nil { - return "", err - } - return string(out), nil -} - -func (s *Server) untrackedPatch(ctx context.Context) (string, error) { - raw, err := s.gitOutput(ctx, "git ls-files", "ls-files", "--others", "--exclude-standard", "-z") - if err != nil { - return "", err - } - - var patch strings.Builder - for _, name := range strings.Split(raw, "\x00") { - if name == "" { - continue - } - out, err := s.gitDiffNoIndex(ctx, name) - if err != nil { - return "", err - } - appendPatch(&patch, out) - } - return patch.String(), nil -} - -func (s *Server) gitDiffNoIndex(ctx context.Context, name string) (string, error) { - cmd := s.gitCommand(ctx, "diff", "--no-ext-diff", "--patch", "--no-index", "--", gitDevNull, name) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return stdout.String(), nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return stdout.String(), nil - } - return "", commandError("git diff --no-index", err, cmd, stderr.String()) -} - -func (s *Server) gitOK(ctx context.Context, args ...string) bool { - return gitcmd.OK(ctx, s.cwd, args...) -} - -func (s *Server) gitBranch(ctx context.Context) string { - ctx, cancel := context.WithTimeout(ctx, gitcmd.DefaultTimeout) - defer cancel() - return gitcmd.Branch(ctx, s.cwd) -} - -func (s *Server) gitOutput(ctx context.Context, label string, args ...string) (string, error) { - cmd := s.gitCommand(ctx, args...) - out, err := cmd.Output() - if err != nil { - return "", commandError(label, err, cmd, "") - } - return string(out), nil -} - -func (s *Server) gitCommand(ctx context.Context, args ...string) *exec.Cmd { - return gitcmd.Command(ctx, s.cwd, args...) -} - -func appendPatch(b *strings.Builder, patch string) { - if patch == "" { - return - } - b.WriteString(patch) - if !strings.HasSuffix(patch, "\n") { - b.WriteByte('\n') - } -} - -func commandError(label string, err error, cmd *exec.Cmd, stderr string) error { - if errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("%s timed out", label) - } - if stderr = strings.TrimSpace(stderr); stderr != "" { - return fmt.Errorf("%s failed: %s", label, stderr) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - stderr := strings.TrimSpace(string(exitErr.Stderr)) - if stderr != "" { - return fmt.Errorf("%s failed: %s", label, stderr) - } - } - if cmd != nil && cmd.Err != nil { - return fmt.Errorf("%s failed: %w", label, cmd.Err) - } - return fmt.Errorf("%s failed: %w", label, err) -} - -func serveIndex(w http.ResponseWriter, r *http.Request, staticFS fs.FS) { - data, err := fs.ReadFile(staticFS, "index.html") - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(data)) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func readJSON(r *http.Request, v any) error { - defer func() { _ = r.Body.Close() }() - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return err - } - return nil -} - -func writeError(w http.ResponseWriter, status int, err error) { - writeJSON(w, status, map[string]string{"error": err.Error()}) -} - -var pullNumber = regexp.MustCompile(`^[1-9][0-9]*$`) -var safePathPartPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - -func safePathPart(s string) bool { - if s == "" || strings.HasPrefix(s, "-") || strings.Contains(s, "..") || strings.ContainsAny(s, `/\`) { - return false - } - return safePathPartPattern.MatchString(s) -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go deleted file mode 100644 index 9907b70..0000000 --- a/internal/server/server_test.go +++ /dev/null @@ -1,1149 +0,0 @@ -package server - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/imfing/diffs-cli/internal/appconfig" -) - -func TestMain(m *testing.M) { - for k, v := range map[string]string{ - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@example.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@example.com", - } { - _ = os.Setenv(k, v) - } - os.Exit(m.Run()) -} - -func TestConfigIncludesCurrentBranch(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "checkout", "-b", "feature/local-title") - - handler, err := New(Config{CWD: dir}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var got struct { - GitBranch string `json:"gitBranch"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.GitBranch != "feature/local-title" { - t.Fatalf("gitBranch = %q, want feature/local-title", got.GitBranch) - } -} - -func TestConfigIncludesUISettingsWhenConfigured(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - - wordWrap := true - lineNumbers := false - lineBackgrounds := true - handler, err := New(Config{ - CWD: dir, - UI: appconfig.UIConfig{ - ColorScheme: "dark", - DiffTheme: "github", - DiffStyle: "unified", - UIFontFamily: `"Inter Variable", system-ui, sans-serif`, - CodeFontFamily: `"JetBrains Mono", ui-monospace, monospace`, - WordWrap: &wordWrap, - LineNumbers: &lineNumbers, - LineBackgrounds: &lineBackgrounds, - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var got struct { - ColorScheme string `json:"colorScheme"` - DiffTheme string `json:"diffTheme"` - DiffStyle string `json:"diffStyle"` - UIFontFamily string `json:"uiFontFamily"` - CodeFontFamily string `json:"codeFontFamily"` - WordWrap bool `json:"wordWrap"` - LineNumbers bool `json:"lineNumbers"` - LineBackgrounds bool `json:"lineBackgrounds"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.ColorScheme != "dark" { - t.Fatalf("colorScheme = %q, want dark", got.ColorScheme) - } - if got.DiffTheme != "github" || got.DiffStyle != "unified" || !got.WordWrap || got.LineNumbers || !got.LineBackgrounds { - t.Fatalf("unexpected UI config: %+v", got) - } - if got.UIFontFamily != `"Inter Variable", system-ui, sans-serif` || got.CodeFontFamily != `"JetBrains Mono", ui-monospace, monospace` { - t.Fatalf("unexpected font config: %+v", got) - } -} - -func TestLocalDiffIncludesUntrackedFilesInUnbornRepo(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - - patch, err := (&Server{cwd: dir}).localDiff(context.Background()) - if err != nil { - t.Fatalf("localDiff() error = %v", err) - } - for _, want := range []string{ - "diff --git a/new.txt b/new.txt", - "new file mode", - "--- /dev/null", - "+++ b/new.txt", - "+hello", - } { - if !strings.Contains(patch, want) { - t.Fatalf("localDiff() missing %q in patch:\n%s", want, patch) - } - } -} - -func TestGitDiffNoIndexUsesDevNullHeader(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - - patch, err := (&Server{cwd: dir}).gitDiffNoIndex(context.Background(), "new.txt") - if err != nil { - t.Fatalf("gitDiffNoIndex() error = %v", err) - } - if !strings.Contains(patch, "--- /dev/null") { - t.Fatalf("gitDiffNoIndex() patch missing /dev/null header:\n%s", patch) - } -} - -func TestLocalDiffIncludesStagedAndUnstagedTrackedChanges(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\nthree\n") - - patch, err := (&Server{cwd: dir}).localDiff(context.Background()) - if err != nil { - t.Fatalf("localDiff() error = %v", err) - } - for _, want := range []string{"+two", "+three"} { - if !strings.Contains(patch, want) { - t.Fatalf("localDiff() missing %q in patch:\n%s", want, patch) - } - } -} - -func TestBranchDiffComparesAgainstBase(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feat") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "two") - - patch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", false) - if err != nil { - t.Fatalf("branchDiff() error = %v", err) - } - if !strings.Contains(patch, "+two") { - t.Fatalf("branchDiff() missing %q in patch:\n%s", "+two", patch) - } -} - -func TestBranchDiffWithDirtyIncludesFinalWorkingTreeAndUntrackedFiles(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - writeFile(t, filepath.Join(dir, "staged.txt"), "old\n") - git(t, dir, "add", "tracked.txt", "staged.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feat") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "two") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\nthree\n") - writeFile(t, filepath.Join(dir, "staged.txt"), "changed\n") - git(t, dir, "add", "staged.txt") - writeFile(t, filepath.Join(dir, "untracked.txt"), "hello\n") - - cleanPatch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", false) - if err != nil { - t.Fatalf("branchDiff(clean) error = %v", err) - } - if !strings.Contains(cleanPatch, "+two") || strings.Contains(cleanPatch, "+three") { - t.Fatalf("branchDiff(clean) should only show committed branch changes:\n%s", cleanPatch) - } - - dirtyPatch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", true) - if err != nil { - t.Fatalf("branchDiff(dirty) error = %v", err) - } - for _, want := range []string{ - "+three", - "+changed", - "diff --git a/untracked.txt b/untracked.txt", - "+hello", - } { - if !strings.Contains(dirtyPatch, want) { - t.Fatalf("branchDiff(dirty) missing %q in patch:\n%s", want, dirtyPatch) - } - } - if strings.Contains(dirtyPatch, "+two") { - t.Fatalf("branchDiff(dirty) should show final working tree content, not stale HEAD content:\n%s", dirtyPatch) - } -} - -func TestPullRequestPatchFetchesFinalDiff(t *testing.T) { - var gotArgs []string - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - gotArgs = append([]string(nil), args...) - return []byte("diff --git a/tracked.txt b/tracked.txt\n"), nil - }) - defer restore() - - patch, err := (&Server{githubHost: "github.example.com"}).pullRequestPatch( - context.Background(), - "org", - "repo", - "123", - ) - if err != nil { - t.Fatalf("pullRequestPatch() error = %v", err) - } - if patch != "diff --git a/tracked.txt b/tracked.txt\n" { - t.Fatalf("pullRequestPatch() = %q", patch) - } - for _, want := range []string{ - "repos/org/repo/pulls/123", - "--hostname", - "github.example.com", - "Accept: application/vnd.github.v3.diff", - } { - if !containsArg(gotArgs, want) { - t.Fatalf("gh args missing %q: %v", want, gotArgs) - } - } - if containsArg(gotArgs, "Accept: application/vnd.github.v3.patch") { - t.Fatalf("gh args should request final diff, not patch: %v", gotArgs) - } -} - -func TestHandleBranchDiffRejectsMissingOrUnsafeBase(t *testing.T) { - srv := &Server{cwd: t.TempDir()} - cases := []struct { - name string - query string - want string - }{ - {name: "missing", query: "", want: "base query parameter is required"}, - {name: "blank", query: "?base=%20%20", want: "required"}, - {name: "flag-like", query: "?base=-rf", want: "invalid base ref"}, - {name: "control char", query: "?base=ma%09in", want: "invalid base ref"}, - {name: "two dot rev", query: "?base=main..feature", want: "invalid base ref"}, - {name: "parent rev", query: "?base=HEAD~1", want: "invalid base ref"}, - {name: "peel rev", query: "?base=HEAD%5E%7Bcommit%7D", want: "invalid base ref"}, - {name: "upstream rev", query: "?base=%40%7Bupstream%7D", want: "invalid base ref"}, - {name: "at shortcut", query: "?base=%40", want: "invalid base ref"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/branch-diff"+tc.query, nil) - w := httptest.NewRecorder() - srv.handleBranchDiff(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", w.Code) - } - if !strings.Contains(w.Body.String(), tc.want) { - t.Fatalf("body = %q, want substring %q", w.Body.String(), tc.want) - } - }) - } -} - -func TestEventsStreamOnLocalFileChange(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { - t.Fatalf("Content-Type = %q, want text/event-stream", got) - } - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case <-seen: - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for diff event") - } -} - -func TestEventsStreamOnGitCommit(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - git(t, dir, "commit", "-m", "two") - - select { - case <-seen: - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for diff event after commit") - } -} - -func TestOnChangeRunsOnLocalFileChange(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - Watch: true, - OnChange: func(paths []ChangedFile) { - select { - case changed <- paths: - default: - } - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case paths := <-changed: - want := []ChangedFile{{Path: "tracked.txt", Action: ChangeModified}} - if len(paths) != len(want) || paths[0] != want[0] { - t.Fatalf("change paths = %+v, want %+v", paths, want) - } - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for change callback") - } -} - -func TestOnChangeIgnoresGitCleanBuildOutput(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - git(t, dir, "add", ".gitignore") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n*.log\n") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist", "assets"), 0o755); err != nil { - t.Fatal(err) - } - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - Watch: true, - OnChange: func(paths []ChangedFile) { - select { - case changed <- paths: - default: - } - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "web", "dist", "assets", "index.js"), "built\n") - - select { - case paths := <-changed: - t.Fatalf("change callback ran for git-ignored build output: %v", paths) - case <-time.After(400 * time.Millisecond): - } -} - -func TestEventsStreamIgnoresGitCleanBuildOutput(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - git(t, dir, "add", ".gitignore") - git(t, dir, "commit", "-m", "init") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist", "assets"), 0o755); err != nil { - t.Fatal(err) - } - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - writeFile(t, filepath.Join(dir, "web", "dist", "assets", "index.js"), "built\n") - - select { - case <-seen: - t.Fatal("received diff event for git-ignored build output") - case <-time.After(400 * time.Millisecond): - } -} - -func TestWatcherDisabledDoesNotObserveLocalChanges(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - OnChange: func(paths []ChangedFile) { - changed <- paths - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case paths := <-changed: - t.Fatalf("change callback ran with watcher disabled: %v", paths) - case <-time.After(400 * time.Millisecond): - } -} - -func TestGitStatusReturnsChangedPaths(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", ".gitignore", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist"), 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(dir, "web", "dist", "bundle.js"), "built\n") - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - want := map[string]ChangeAction{ - "new.txt": ChangeAdded, - "tracked.txt": ChangeModified, - } - if len(got) != len(want) { - t.Fatalf("gitStatus() = %+v, want %+v", got, want) - } - for path, action := range want { - if got[path] != action { - t.Fatalf("gitStatus()[%q] = %q, want %q", path, got[path], action) - } - } -} - -func TestGitStatusActions(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - if err := os.Remove(filepath.Join(dir, "tracked.txt")); err != nil { - t.Fatal(err) - } - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - want := map[string]ChangeAction{ - "new.txt": ChangeAdded, - "tracked.txt": ChangeDeleted, - } - if len(got) != len(want) { - t.Fatalf("gitStatus() = %+v, want %+v", got, want) - } - for path, action := range want { - if got[path] != action { - t.Fatalf("gitStatus()[%q] = %q, want %q", path, got[path], action) - } - } -} - -func TestGitStatusUsesNewPathForRenames(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "old.txt"), "one\n") - git(t, dir, "add", "old.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "mv", "old.txt", "new.txt") - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - if got["new.txt"] != ChangeRenamed { - t.Fatalf("gitStatus()[new.txt] = %q, want %q; full map: %+v", got["new.txt"], ChangeRenamed, got) - } - if _, ok := got["old.txt"]; ok { - t.Fatalf("gitStatus() should not key renamed file by old path: %+v", got) - } - - changed := changedFilesForEvents([]string{"new.txt"}, got) - want := []ChangedFile{{Path: "new.txt", Action: ChangeRenamed}} - if len(changed) != len(want) || changed[0] != want[0] { - t.Fatalf("changedFilesForEvents() = %+v, want %+v", changed, want) - } -} - -func TestChangedFilesForEventsKeepsActions(t *testing.T) { - got := changedFilesForEvents( - []string{"src"}, - map[string]ChangeAction{ - ".gitignore": ChangeModified, - "src/new.go": ChangeAdded, - }, - ) - want := []ChangedFile{{Path: "src/new.go", Action: ChangeAdded}} - if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("changedFilesForEvents() = %+v, want %+v", got, want) - } -} - -func TestSafePathPartRejectsOptionLikeParts(t *testing.T) { - for _, part := range []string{"-h", "../org", "org/repo", `org\repo`, "bad space"} { - if safePathPart(part) { - t.Fatalf("safePathPart(%q) = true, want false", part) - } - } - for _, part := range []string{"imfing", "diffs-cli", "repo.name", "repo_name"} { - if !safePathPart(part) { - t.Fatalf("safePathPart(%q) = false, want true", part) - } - } -} - -func TestLocalCommentsAPI(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "config", "user.name", "Test") - git(t, dir, "checkout", "-b", "feature/comments") - handler, err := New(Config{CWD: dir}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - addReq := httptest.NewRequest(http.MethodPost, "/api/comments", bytes.NewBufferString(`{ - "path": "web/src/App.tsx", - "line": 42, - "side": "additions", - "body": "Looks odd", - "author": "agent" - }`)) - addRec := httptest.NewRecorder() - handler.ServeHTTP(addRec, addReq) - if addRec.Code != http.StatusCreated { - t.Fatalf("add status = %d, body = %s", addRec.Code, addRec.Body.String()) - } - var thread struct { - ID string `json:"id"` - Branch string `json:"branch"` - Path string `json:"path"` - Status string `json:"status"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.NewDecoder(addRec.Body).Decode(&thread); err != nil { - t.Fatal(err) - } - if thread.ID == "" || thread.Branch != "feature/comments" || thread.Path != "web/src/App.tsx" || thread.Status != "open" { - t.Fatalf("unexpected thread: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != "agent" || thread.Comments[0].Body != "Looks odd" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } - - replyReq := httptest.NewRequest(http.MethodPost, "/api/comments/"+thread.ID+"/replies", bytes.NewBufferString(`{"body":"Agreed"}`)) - replyRec := httptest.NewRecorder() - handler.ServeHTTP(replyRec, replyReq) - if replyRec.Code != http.StatusOK { - t.Fatalf("reply status = %d, body = %s", replyRec.Code, replyRec.Body.String()) - } - var replied struct { - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.NewDecoder(replyRec.Body).Decode(&replied); err != nil { - t.Fatal(err) - } - if len(replied.Comments) != 2 || replied.Comments[1].Author != "Test" || replied.Comments[1].Body != "Agreed" { - t.Fatalf("unexpected reply comments: %+v", replied.Comments) - } - - resolveReq := httptest.NewRequest(http.MethodPost, "/api/comments/"+thread.ID+"/resolve", nil) - resolveRec := httptest.NewRecorder() - handler.ServeHTTP(resolveRec, resolveReq) - if resolveRec.Code != http.StatusOK { - t.Fatalf("resolve status = %d, body = %s", resolveRec.Code, resolveRec.Body.String()) - } - - listReq := httptest.NewRequest(http.MethodGet, "/api/comments", nil) - listRec := httptest.NewRecorder() - handler.ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list status = %d, body = %s", listRec.Code, listRec.Body.String()) - } - var list struct { - Threads []struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"threads"` - } - if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { - t.Fatal(err) - } - if len(list.Threads) != 1 || list.Threads[0].ID != thread.ID || list.Threads[0].Status != "resolved" { - t.Fatalf("list = %+v, want resolved thread", list.Threads) - } - - deleteReq := httptest.NewRequest(http.MethodDelete, "/api/comments/"+thread.ID, nil) - deleteRec := httptest.NewRecorder() - handler.ServeHTTP(deleteRec, deleteReq) - if deleteRec.Code != http.StatusNoContent { - t.Fatalf("delete status = %d, body = %s", deleteRec.Code, deleteRec.Body.String()) - } - - listRec = httptest.NewRecorder() - handler.ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list after delete status = %d, body = %s", listRec.Code, listRec.Body.String()) - } - if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { - t.Fatal(err) - } - if len(list.Threads) != 0 { - t.Fatalf("list after delete = %+v, want no threads", list.Threads) - } -} - -func TestGitHubCommentsAPIListsReviewThreads(t *testing.T) { - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - if len(args) >= 2 && args[0] == "api" && args[1] == "graphql" { - return []byte(`{ - "data": { - "repository": { - "pullRequest": { - "reviewThreads": { - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "PRRT_kwDO", - "isResolved": false, - "path": "web/src/App.tsx", - "line": 42, - "comments": { - "nodes": [{ - "id": "PRRC_kwDO", - "databaseId": 1001, - "author": {"login": "octocat"}, - "body": "Looks odd", - "path": "web/src/App.tsx", - "line": 42, - "side": "RIGHT", - "url": "https://github.com/o/r/pull/1#discussion", - "createdAt": "2026-05-23T12:00:00Z" - }] - } - }] - } - } - } - } - }`), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/comments?org=org&repo=repo&number=123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - var response struct { - Threads []struct { - ID string `json:"id"` - Provider string `json:"provider"` - Path string `json:"path"` - Line int `json:"line"` - Side string `json:"side"` - Status string `json:"status"` - ReplyToID int64 `json:"replyToId"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } `json:"threads"` - } - if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { - t.Fatal(err) - } - if len(response.Threads) != 1 { - t.Fatalf("threads = %+v, want one thread", response.Threads) - } - thread := response.Threads[0] - if thread.ID != "PRRT_kwDO" || thread.Provider != "github" || thread.Path != "web/src/App.tsx" || thread.Line != 42 || thread.Side != "additions" || thread.Status != "open" || thread.ReplyToID != 1001 { - t.Fatalf("unexpected thread: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != "octocat" || thread.Comments[0].Body != "Looks odd" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } -} - -func TestGitHubPullRequestInfo(t *testing.T) { - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - if strings.Contains(strings.Join(args, " "), "repos/org/repo/pulls/123") { - return []byte(`{ - "title": "Add compact PR header", - "state": "open", - "draft": false, - "merged": false, - "user": {"login": "octocat"}, - "created_at": "2026-05-22T12:00:00Z", - "updated_at": "2026-05-23T12:00:00Z", - "additions": 10, - "deletions": 2, - "changed_files": 3, - "commits": 4, - "head": { - "sha": "abc123", - "ref": "feature", - "label": "contrib:feature", - "repo": {"full_name": "contrib/repo"} - }, - "base": { - "ref": "main", - "label": "org:main", - "repo": {"full_name": "org/repo"} - } - }`), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/pull/org/repo/123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - var got struct { - Title string `json:"title"` - State string `json:"state"` - Author string `json:"author"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - ChangedFiles int `json:"changedFiles"` - Commits int `json:"commits"` - HeadRef string `json:"headRef"` - HeadLabel string `json:"headLabel"` - HeadRepo string `json:"headRepo"` - BaseRef string `json:"baseRef"` - BaseLabel string `json:"baseLabel"` - BaseRepo string `json:"baseRepo"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.Title != "Add compact PR header" || got.State != "open" || got.Author != "octocat" || - got.Additions != 10 || got.Deletions != 2 || got.ChangedFiles != 3 || got.Commits != 4 || - got.HeadRef != "feature" || got.HeadLabel != "contrib:feature" || got.HeadRepo != "contrib/repo" || - got.BaseRef != "main" || got.BaseLabel != "org:main" || got.BaseRepo != "org/repo" { - t.Fatalf("unexpected pull request info: %+v", got) - } -} - -func TestGitHubCommentsAPICreatesReviewComment(t *testing.T) { - var createdArgs []string - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - joined := strings.Join(args, " ") - switch { - case strings.Contains(joined, "repos/org/repo/pulls/123") && !strings.Contains(joined, "comments"): - return []byte(`{"head":{"sha":"abc123"}}`), nil - case strings.Contains(joined, "repos/org/repo/pulls/123/comments"): - createdArgs = append([]string(nil), args...) - return []byte(`{"id":1001,"node_id":"PRRC_kwDO"}`), nil - case len(args) >= 2 && args[0] == "api" && args[1] == "graphql": - return []byte(githubReviewThreadsFixture(false)), nil - default: - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - } - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/api/comments?org=org&repo=repo&number=123", bytes.NewBufferString(`{ - "path": "web/src/App.tsx", - "line": 40, - "endLine": 42, - "side": "additions", - "body": "Looks odd" - }`)) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - for _, want := range []string{ - "body=Looks odd", - "commit_id=abc123", - "path=web/src/App.tsx", - "line=42", - "start_line=40", - } { - if !containsArg(createdArgs, want) { - t.Fatalf("create args missing %q: %v", want, createdArgs) - } - } -} - -func TestGitHubCommentsAPIResolvesReviewThread(t *testing.T) { - var sawResolve bool - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - joined := strings.Join(args, " ") - if strings.Contains(joined, "resolveReviewThread") { - sawResolve = true - if !containsArg(args, "threadID=PRRT_kwDO") { - t.Fatalf("resolve args missing thread id: %v", args) - } - return []byte(`{"data":{"resolveReviewThread":{"thread":{"id":"PRRT_kwDO","isResolved":true}}}}`), nil - } - if len(args) >= 2 && args[0] == "api" && args[1] == "graphql" { - return []byte(githubReviewThreadsFixture(true)), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/api/comments/PRRT_kwDO/resolve?org=org&repo=repo&number=123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - if !sawResolve { - t.Fatal("resolveReviewThread mutation was not called") - } - var thread struct { - Status string `json:"status"` - } - if err := json.NewDecoder(rec.Body).Decode(&thread); err != nil { - t.Fatal(err) - } - if thread.Status != "resolved" { - t.Fatalf("status = %q, want resolved", thread.Status) - } -} - -func TestLocalWatcherIgnoresChmodEvents(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - event := fsnotify.Event{Name: filepath.Join(dir, "tracked.txt"), Op: fsnotify.Chmod} - - if w.shouldSchedule(event) { - t.Fatal("chmod-only events should not schedule refreshes") - } -} - -func TestLocalWatcherIgnoresGitDirectory(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - name := filepath.Join(dir, ".git", "HEAD") - - if !w.ignore(name) { - t.Fatal(".git paths should be ignored") - } -} - -func TestLocalWatcherIgnoresCommentTempFiles(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - name := filepath.Join(dir, ".diffs", ".comments-123.json") - - if !w.ignore(name) { - t.Fatal("comment temp files should be ignored") - } -} - -func TestResolveBranchBaseInfersInPriorityOrder(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "f.txt"), "one\n") - git(t, dir, "add", "f.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feature") - // A base that exists only as a remote-tracking ref (origin/). - git(t, dir, "update-ref", "refs/remotes/origin/release", "HEAD") - - srv := &Server{cwd: dir} - tests := []struct { - name string - prBase string - repoDefault string - want string - }{ - {"pr base wins over repo default", "main", "release", "main"}, - {"repo default when no pr base", "", "main", "main"}, - {"main/master fallback when neither given", "", "", "main"}, - {"origin/ fallback for remote-only ref", "release", "", "origin/release"}, - {"skips unresolvable refs", "ghost", "main", "main"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := srv.resolveBranchBase(context.Background(), tt.prBase, tt.repoDefault) - if got != tt.want { - t.Fatalf("resolveBranchBase(%q, %q) = %q, want %q", tt.prBase, tt.repoDefault, got, tt.want) - } - }) - } -} - -func TestResolveBranchBaseReturnsEmptyWhenNothingResolves(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "trunk") - writeFile(t, filepath.Join(dir, "f.txt"), "one\n") - git(t, dir, "add", "f.txt") - git(t, dir, "commit", "-m", "init") - - // No main/master and no origin remote, so every candidate fails. - if got := (&Server{cwd: dir}).resolveBranchBase(context.Background(), "", ""); got != "" { - t.Fatalf("resolveBranchBase() = %q, want empty", got) - } -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} - -func writeFile(t *testing.T, name, content string) { - t.Helper() - if err := os.WriteFile(name, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func stubGH(t *testing.T, fn func(context.Context, ...string) ([]byte, error)) func() { - t.Helper() - previous := runGH - runGH = fn - return func() { - runGH = previous - } -} - -func containsArg(args []string, want string) bool { - for _, arg := range args { - if arg == want { - return true - } - } - return false -} - -func githubReviewThreadsFixture(resolved bool) string { - return fmt.Sprintf(`{ - "data": { - "repository": { - "pullRequest": { - "reviewThreads": { - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "PRRT_kwDO", - "isResolved": %t, - "path": "web/src/App.tsx", - "line": 42, - "comments": { - "nodes": [{ - "id": "PRRC_kwDO", - "databaseId": 1001, - "author": {"login": "octocat"}, - "body": "Looks odd", - "path": "web/src/App.tsx", - "line": 42, - "side": "RIGHT", - "url": "https://github.com/o/r/pull/1#discussion", - "createdAt": "2026-05-23T12:00:00Z" - }] - } - }] - } - } - } - } - }`, resolved) -} diff --git a/internal/server/watch.go b/internal/server/watch.go deleted file mode 100644 index 93238a8..0000000 --- a/internal/server/watch.go +++ /dev/null @@ -1,416 +0,0 @@ -package server - -import ( - "context" - "maps" - "os" - "path/filepath" - "slices" - "strings" - "sync" - "time" - - "github.com/fsnotify/fsnotify" - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -const ( - watchDebounce = 150 * time.Millisecond - gitStatusTimeout = 2 * time.Second - gitStateEvent = ".git" -) - -type changeBroadcaster struct { - mu sync.Mutex - clients map[chan struct{}]struct{} -} - -func newChangeBroadcaster() *changeBroadcaster { - return &changeBroadcaster{clients: make(map[chan struct{}]struct{})} -} - -func (b *changeBroadcaster) subscribe(ctx context.Context) <-chan struct{} { - ch := make(chan struct{}, 1) - b.mu.Lock() - b.clients[ch] = struct{}{} - b.mu.Unlock() - - go func() { - <-ctx.Done() - b.mu.Lock() - delete(b.clients, ch) - b.mu.Unlock() - }() - - return ch -} - -func (b *changeBroadcaster) broadcast() { - b.mu.Lock() - defer b.mu.Unlock() - for ch := range b.clients { - select { - case ch <- struct{}{}: - default: - } - } -} - -type localWatcher struct { - cwd string - gitDir string - watcher *fsnotify.Watcher - - mu sync.Mutex - watched map[string]struct{} -} - -type ChangeAction string - -const ( - ChangeAdded ChangeAction = "added" - ChangeModified ChangeAction = "modified" - ChangeDeleted ChangeAction = "deleted" - ChangeRenamed ChangeAction = "renamed" -) - -type ChangedFile struct { - Path string - Action ChangeAction -} - -func newLocalWatcher(cwd string, notify func([]string)) (*localWatcher, error) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - - w := &localWatcher{ - cwd: cwd, - watcher: watcher, - watched: make(map[string]struct{}), - } - if err := w.addDirRecursive(cwd); err != nil { - _ = watcher.Close() - return nil, err - } - w.gitDir = absoluteGitDir(cwd) - w.addGitStateDirs() - - go w.run(notify) - return w, nil -} - -func (w *localWatcher) run(notify func([]string)) { - var timer *time.Timer - var timerC <-chan time.Time - pending := make(map[string]struct{}) - schedule := func(name string) { - pending[w.displayName(name)] = struct{}{} - if timer == nil { - timer = time.NewTimer(watchDebounce) - timerC = timer.C - return - } - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - timer.Reset(watchDebounce) - } - - for { - select { - case event, ok := <-w.watcher.Events: - if !ok { - if timer != nil { - timer.Stop() - } - return - } - if w.isGitStateEvent(event.Name) { - if event.Has(fsnotify.Create) { - w.addGitStateDirRecursive(event.Name) - } - if w.shouldSchedule(event) { - schedule(gitStateEvent) - } - continue - } - if w.ignore(event.Name) { - continue - } - if event.Has(fsnotify.Create) { - w.addCreatedDir(event.Name) - } - if w.shouldSchedule(event) { - schedule(event.Name) - } - case _, ok := <-w.watcher.Errors: - if !ok { - if timer != nil { - timer.Stop() - } - return - } - case <-timerC: - paths := sortedKeys(pending) - pending = make(map[string]struct{}) - timerC = nil - timer = nil - notify(paths) - } - } -} - -func (w *localWatcher) shouldSchedule(event fsnotify.Event) bool { - return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) -} - -func (w *localWatcher) addCreatedDir(name string) { - info, err := os.Stat(name) - if err != nil || !info.IsDir() { - return - } - _ = w.addDirRecursive(name) -} - -func (w *localWatcher) addDirRecursive(root string) error { - return filepath.WalkDir(root, func(name string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() { - return nil - } - if w.skipDir(name) { - return filepath.SkipDir - } - return w.addDir(name) - }) -} - -func (w *localWatcher) addDir(name string) error { - abs, err := filepath.Abs(name) - if err != nil { - return err - } - w.mu.Lock() - defer w.mu.Unlock() - if _, ok := w.watched[abs]; ok { - return nil - } - if err := w.watcher.Add(abs); err != nil { - return err - } - w.watched[abs] = struct{}{} - return nil -} - -func (w *localWatcher) addGitStateDirs() { - if w.gitDir == "" { - return - } - for _, dir := range []string{ - w.gitDir, - filepath.Join(w.gitDir, "refs"), - filepath.Join(w.gitDir, "logs"), - } { - w.addGitStateDirRecursive(dir) - } -} - -func (w *localWatcher) addGitStateDirRecursive(root string) { - info, err := os.Stat(root) - if err != nil || !info.IsDir() { - return - } - _ = filepath.WalkDir(root, func(name string, entry os.DirEntry, err error) error { - if err != nil || !entry.IsDir() { - return nil - } - _ = w.addDir(name) - return nil - }) -} - -func (w *localWatcher) isGitStateEvent(name string) bool { - if w.gitDir == "" { - return false - } - rel, err := filepath.Rel(w.gitDir, name) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return false - } - if rel == "." { - return true - } - part := strings.Split(rel, string(filepath.Separator))[0] - switch part { - case "HEAD", "index", "index.lock", "packed-refs", "packed-refs.lock", "refs", "logs", "COMMIT_EDITMSG": - return true - default: - return false - } -} - -func (w *localWatcher) ignore(name string) bool { - if w.isCommentTempFile(name) { - return true - } - return skippedPathPart(w.cwd, name) -} - -func (w *localWatcher) skipDir(name string) bool { - if name == w.cwd { - return false - } - return skippedPathPart(w.cwd, name) -} - -func (w *localWatcher) displayName(name string) string { - rel, err := filepath.Rel(w.cwd, name) - if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { - return filepath.ToSlash(name) - } - return filepath.ToSlash(rel) -} - -func (w *localWatcher) isCommentTempFile(name string) bool { - rel, err := filepath.Rel(w.cwd, name) - if err != nil { - return false - } - return strings.HasPrefix(rel, ".diffs"+string(filepath.Separator)+".comments-") && strings.HasSuffix(rel, ".json") -} - -func sortedKeys(values map[string]struct{}) []string { - return slices.Sorted(maps.Keys(values)) -} - -func absoluteGitDir(cwd string) string { - ctx, cancel := context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) - defer cancel() - - out, err := gitcmd.Run(ctx, cwd, "rev-parse", "--absolute-git-dir") - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func gitStatus(cwd string) (map[string]ChangeAction, error) { - ctx, cancel := context.WithTimeout(context.Background(), gitStatusTimeout) - defer cancel() - - out, err := gitcmd.Run(ctx, cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all") - if err != nil { - return nil, err - } - statusByPath := make(map[string]ChangeAction) - entries := strings.Split(string(out), "\x00") - for i := 0; i < len(entries); i++ { - entry := entries[i] - if len(entry) < 4 { - continue - } - status := entry[:2] - path := entry[3:] - if status[0] == 'R' || status[0] == 'C' { - // With porcelain -z, rename/copy records are "XY new\0old\0". - // Keep the new path as the status key and only consume the old path. - i++ - if i >= len(entries) { - continue - } - } - if path == "" || skippedPathPart(cwd, filepath.FromSlash(path)) { - continue - } - statusByPath[filepath.ToSlash(path)] = gitStatusAction(status) - } - return statusByPath, nil -} - -func hasGitStateEvent(events []string) bool { - return slices.Contains(events, gitStateEvent) -} - -func changedFilesForEvents(events []string, statusByPath map[string]ChangeAction) []ChangedFile { - if len(events) == 0 || len(statusByPath) == 0 { - return nil - } - matches := make(map[string]ChangedFile) - for _, eventPath := range events { - eventPath = cleanEventPath(eventPath) - if eventPath == "" { - continue - } - if action, ok := statusByPath[eventPath]; ok { - matches[eventPath] = ChangedFile{Path: eventPath, Action: action} - continue - } - prefix := eventPath + "/" - for path, action := range statusByPath { - if strings.HasPrefix(path, prefix) { - matches[path] = ChangedFile{Path: path, Action: action} - } - } - } - return sortedChangedFiles(matches) -} - -func changedFilesFromEvents(events []string) []ChangedFile { - files := make(map[string]ChangedFile) - for _, path := range events { - path = cleanEventPath(path) - if path == "" { - continue - } - files[path] = ChangedFile{Path: path, Action: ChangeModified} - } - return sortedChangedFiles(files) -} - -func cleanEventPath(path string) string { - return strings.Trim(filepath.ToSlash(path), "/") -} - -func gitStatusAction(status string) ChangeAction { - if strings.Contains(status, "D") { - return ChangeDeleted - } - if strings.ContainsAny(status, "RC") { - return ChangeRenamed - } - if strings.Contains(status, "A") || status == "??" { - return ChangeAdded - } - return ChangeModified -} - -func sortedChangedFiles(values map[string]ChangedFile) []ChangedFile { - paths := slices.Sorted(maps.Keys(values)) - - files := make([]ChangedFile, 0, len(paths)) - for _, path := range paths { - files = append(files, values[path]) - } - return files -} - -func skippedPathPart(root, name string) bool { - rel, err := filepath.Rel(root, name) - if err != nil || rel == "." { - return false - } - for _, part := range strings.Split(rel, string(filepath.Separator)) { - switch part { - case ".git", ".hg", ".svn", "node_modules": - return true - } - } - return false -} diff --git a/internal/webassets/dist/.gitkeep b/internal/webassets/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/webassets/embed.go b/internal/webassets/embed.go deleted file mode 100644 index 1c1a116..0000000 --- a/internal/webassets/embed.go +++ /dev/null @@ -1,13 +0,0 @@ -package webassets - -import ( - "embed" - "io/fs" -) - -//go:embed all:dist -var dist embed.FS - -func DistFS() (fs.FS, error) { - return fs.Sub(dist, "dist") -} diff --git a/web/go.mod b/web/go.mod deleted file mode 100644 index 061c92d..0000000 --- a/web/go.mod +++ /dev/null @@ -1,4 +0,0 @@ -module github.com/imfing/diffs-cli/web-src - -go 1.26 - From d37a8aa5313e9370ff5301a2909d73233447b1b3 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:06:58 +0100 Subject: [PATCH 03/14] ci: migrate CI and release to Rust toolchain Replace the Go/GoReleaser pipeline with cargo-based workflows: CI runs fmt, clippy, and a cross-platform test matrix (web assets built once and shared via artifact for the rust-embed compile-time dependency); release builds native binaries across linux/darwin/windows x amd64/arm64, gates on fmt+clippy+test, publishes archives with checksums, and updates the Homebrew cask. Align Cargo.toml to 0.3.0 so the tag-version guard matches the current release line; drop the Go section from .gitignore. --- .github/workflows/ci.yml | 128 ++++++++++------- .github/workflows/release.yml | 253 +++++++++++++++++++++++++++++++--- .gitignore | 7 - Cargo.lock | 2 +- Cargo.toml | 2 +- assets/readme-gopher.jpg | Bin 118310 -> 0 bytes 6 files changed, 314 insertions(+), 78 deletions(-) delete mode 100644 assets/readme-gopher.jpg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f69062..3202847 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,28 +13,12 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 +env: + CARGO_TERM_COLOR: always - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache-dependency-path: go.sum - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.12 - - test: - name: Test +jobs: + web: + name: Build web assets runs-on: ubuntu-latest steps: @@ -51,50 +35,92 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache-dependency-path: go.sum - - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run tests - run: pnpm test + - name: Lint web + run: pnpm --dir web lint + + - name: Build web + run: pnpm --dir web build - snapshot: - name: Snapshot build + - name: Upload web assets + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + retention-days: 1 + + fmt: + name: Format runs-on: ubuntu-latest - needs: - - lint - - test steps: - name: Checkout uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable with: - fetch-depth: 0 + components: rustfmt - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - name: Check formatting + run: cargo fmt --all --check - - name: Setup Node - uses: actions/setup-node@v6 + clippy: + name: Clippy + runs-on: ubuntu-latest + needs: web + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + name: web-dist + path: web/dist - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable with: - go-version-file: go.mod - cache-dependency-path: go.sum + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings - - name: Run GoReleaser snapshot - uses: goreleaser/goreleaser-action@v7 + test: + name: Test + needs: web + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v4 with: - distribution: goreleaser - version: "~> v2" - args: release --snapshot --clean + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7bec95..ff4c0c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,16 +8,28 @@ on: permissions: contents: write +env: + CARGO_TERM_COLOR: always + jobs: - release: - name: Release + web: + name: Build web assets runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - with: - fetch-depth: 0 + + - name: Verify version matches tag + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + crate="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + if [ "$tag" != "$crate" ]; then + echo "::error::tag v$tag does not match Cargo.toml version $crate" + exit 1 + fi + echo "Releasing version $crate" - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -29,24 +41,229 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache-dependency-path: go.sum - - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint web + run: pnpm --dir web lint + + - name: Build web + run: pnpm --dir web build + + - name: Upload web assets + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + retention-days: 1 + + check: + name: Check + needs: web + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features --locked -- -D warnings + - name: Run tests - run: pnpm test + run: cargo test --all-targets --locked + + build: + name: Build ${{ matrix.os_label }}_${{ matrix.arch_label }} + needs: check + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + os_label: linux + arch_label: amd64 + - runner: ubuntu-24.04-arm + os_label: linux + arch_label: arm64 + - runner: macos-13 + os_label: darwin + arch_label: amd64 + - runner: macos-14 + os_label: darwin + arch_label: arm64 + - runner: windows-latest + os_label: windows + arch_label: amd64 + - runner: windows-11-arm + os_label: windows + arch_label: arm64 + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v6 - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + - name: Download web assets + uses: actions/download-artifact@v4 with: - distribution: goreleaser - version: "~> v2" - args: release --clean + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.os_label }}-${{ matrix.arch_label }} + + - name: Build release binary + run: cargo build --release --locked + + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + name="diffs_${version}_${{ matrix.os_label }}_${{ matrix.arch_label }}" + mkdir -p "dist/${name}" + cp target/release/diffs "dist/${name}/" + cp LICENSE README.md "dist/${name}/" + tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = "${env:GITHUB_REF_NAME}".TrimStart("v") + $name = "diffs_${version}_${{ matrix.os_label }}_${{ matrix.arch_label }}" + New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null + Copy-Item target/release/diffs.exe "dist/$name/" + Copy-Item LICENSE,README.md "dist/$name/" + Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: archive-${{ matrix.os_label }}-${{ matrix.arch_label }} + path: | + dist/*.tar.gz + dist/*.zip + if-no-files-found: error + retention-days: 1 + + release: + name: Publish release + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download archives + uses: actions/download-artifact@v4 + with: + pattern: archive-* + path: dist + merge-multiple: true + + - name: Generate checksums + shell: bash + working-directory: dist + run: | + shasum -a 256 * > checksums.txt + cat checksums.txt + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + generate_release_notes: true + + homebrew: + name: Update Homebrew cask + needs: release + runs-on: ubuntu-latest + if: ${{ !contains(github.ref_name, '-') }} + + steps: + - name: Download archives + uses: actions/download-artifact@v4 + with: + pattern: archive-* + path: dist + merge-multiple: true + + - name: Render cask + id: cask + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + sha_arm="$(shasum -a 256 "dist/diffs_${version}_darwin_arm64.tar.gz" | awk '{print $1}')" + sha_intel="$(shasum -a 256 "dist/diffs_${version}_darwin_amd64.tar.gz" | awk '{print $1}')" + mkdir -p out/Casks + cat > out/Casks/diffs.rb <(4Jh+3HWrKLG*( z00RC2Kik1ou;RZ11rPM{v1--Im8(`MU{=FF3MvYU3h+xsNmXeL{JT-@*I(5(YHnD+ zL30a%u;q91?`CFf#Q*OFem(~(m=$p=i&h|108$09LIv^jCD^_En8+0fc!2+XAdsjP z=#{H5tFiz6bossi^AP`}qkdu7D>huTX~XEoJDi#GnWTM9R8oonmujC=TFa z1vn%!hRy&)6|#g7h7T2pkN`LmV#wGqzJN*9)y1)CsZ?<5F9rap)RXXaSmx?;0v0mH z981f@BVz>sVUFWt!Vs1WB7v-BLpKL37EyvnU!wzMCWeJpBQ#lvkWHaYJR%Z#6>Cn7 zD1N3RN!7uwCaR%QZIvZyA}T+dAV}4*wXkOBk%%Y}Um2YT=vWC4m96?V%pPlwY+_=N z&yyeB$yAB>k))W8r>lYZHXR92vGiPhS^}u?A#MnBq>U#^%qP)VyVr zql?0Y*~O=cZ3!ZTIToNuECr@HnM!DaCl^xz5=+d-6ajX8TAD}~RmcNjqHCum5k-JW z4bu@n0{m<|Kw#-=s6sq8lqf+82)tAqtOXH10Z?APK!_(@0xy<&lqx3D&2`CY%D516 zKD7uS$&4ZZtk3dkG3p||7|Vkfv0)+wbZkBpVK&Mkwp}}3q(WyD>DZ~ZYZt%9u2!?v zE<#~b-B&WnOaX9IdB4yi77-~kj|9W-rqaq(+_Bue0p0~9;*ET2b? z08nxGVm1I0krtE?lmai+jTshZ$qr#b=`mxQ09`fT9EWg=7qO9KD!dMSCxWH}}M;kn2v7;SCUOk#HMWaFgNkRm_Pf7TMDBv?Y~=$x^2RWq2j1 z5;(m_SOyco$0stWOe$P2063m44 zgw0tVAY?)j!aMLWOdJ6AfG-Ng#l`cGfJHB+Lj40;3>+KYh|mBwbeO%th5B|627w6su^ zEuOusTVw#Bn!^9{t*P{UvLz9!qk|sQ5i$V_SDvk{ewD3@FBVZFS)`m$G9$|z7cT+; zpLSKb2nrK{pa~E-WpX|MNYzjFgh)3gI@TffJycf*WpNlv?7)wUPlE!5ijEWkOmQs{ zn@{5*aPTfXlJ!}hH8r2k)M5gpxX>OT9YDU7RW?5UDjT{Axb$V!rqUT=T_WH=Dgv=h zNJlsL1;p^@XJb5}P_ka@q^iM%@JUeanYtt%2AdBri-F!yM}$IalX7C;@{rI*aSA6? zSWIhXHvT_5kQ7^Z{+$gxBTfS!V9b5;Ot+L}fpVnp_+Vt5lm zJDyKPuwy)tynG-A(Bm}4F{uFPNLUCg^s%}~2fY|eMwS-b(o{8UK0TA1k3sTu-0^%d zDNRQNHx{ZNG`3=@2v{hvpoG=5#SY47OHUlYuy7Vx2oMK9Dh?CzkXIv_Vxm1E6T!ej z_lYV5A!jWL2_hg?eaiz($@O!!1RfG8K=LHeJ+Afy&}kyc`Rar$sDLpHF_Q{+Pox56 zmk-?>Q{^n5Zh0Dk1=tuW)M_1Zo|-xI_fVe@>S+WCHUd~v5lAspghcW!(8@S%1saKN z7cVa4rHMo6co+f{&*$G!p_{9VZCH*r@EODaU}-!Z0uQKLo~F}*c9sqj#RPEGh!b@2 zvaUieOO9ZxX1yjzuF=iO@HuD#bsSu9%()mE$Rq=82P6(Hu(D=30A!oiIRT!EE~cVv zwW#(eTZ9b~iihD4`_3&sR1FLDG8PRG%xrBDTZI-z<)I3pKWWoR#jeKYzqHjBV}V4c zNLvi`5=+MvTe8Dc&|(qv_5vVDO9Mo0cxG`eT75mENZWz8tQ}eTy3l}Z?V)~UE31oD zJdv>^0h*es{uZ`heBTE}n>`79(_+ zv@iyeAG#bR)Wv#qJFEp1@N(d#h2qcz5$!z&7Y44K3x#16DL|{ikQ0mK$5085ZVnM~ zOcGs-7K_EC)snTSx7=`4)h3cSGz+TZzbq08%2S37}F88WUZ?{JIynrqFX$$S zcr*igJ7^FGdvpbL_ilTiw)2OB3i1s zs)VP*1Yrn5Qy5AYIPmBIUJ*dQ$CEHoKum-#l!T1Mp||tcydt2&Ru)IXgdhZ79LE!5 z)LYr14k0W@mK7TMPSv+6Ea>&MY!$_kENDjN=uCAUki_vpz;W3a#V`BEA?d0^Zi=4k zsV|sRWz0#D;Pvb9^`5|8xw_MMKi&`l{zc_5PT7gB-R(W{D^kZ0nP<427;&;R)qIE3 zfz_vULac1>ujYr`xebu(KxnAG&JHT+y`aZ%_1*px`-&4Lw;mJD?B8GVtw5}%2H*~< zehRgEhlCo`rUUmg6J}E|y78bHu0Bo8B*UEyW8qLlKxJ9c?U;fhsC&B3j`mCYFE4qA z_w`uhSne~;yn#4C72)-mcK824p>(Kg7(Awh2xS<-`lx#{&LRhTM3Bx$03DhInZ*No zZd%MbApSj*7J-cWm#GzlxS^OPsLsFs=H9?Ukg%D%^~30x&9WNuvm%hTNTB*DB3`7d z#!ygT5vfe%YcV|&<}P9&BJ}ORg*gbqGGXvf6D`M=w>*sD2l>>%KUM2zo`mw4ctn`1 zy*f$_gE$~^Wh%eEs_lS#{pZfBirM&m>#A4UV%^#5Y6uKNU92c^UH@_AN(+=Bk4Q|l zvIC^2%Bokj-RTuBElDV~4W;&Gy zeuC}e&cgbkqB-wz%HxqIK|jImpTK4Iphk-l_ub&4Yfq)?>v>Me8?%J=&^!Qry1-() z$dK=T6!p87Ih~~nbxERol1PS-LAKn#;_aL6RJ+ly3}*?f|Lf8>baDuC4-w@;~7Jm;sEa>6*s6DMi^e(!=AnJ_Z_K>eV7$m zG-{DxCH}Q?*!D2YNz}}Gt!LzZkAxx@zT3RGtwFy|BXMs%8HOu7;Ljt1@Iu$1W zA#U-!kh3)2^RAwRyuO95#A{CFrd`<-gQbFD|G>{{?~QBTsbVaho@;p)y#G5_=JKJY zSNNG?vM@52@M^^5^0OV!_X?K!{;1+yVsdJ=$$YyVSY9g z4O1NQBid8G2#KbO;M%ANY(AAtwT*@DxdbIL;^>R_8s%*4sm#ZoR*sJpx* zyfsxed(;N|7gKb`K|rN}@jn!o z&eNgc=*S!_6{GZjAD+%5NcL&dt~wGzVT469vKTfjnAm3G&_H#$23XEHR-abG@w6DC z2x2iEgE-2_X7Ey(irZ$t4#|aPvhJ60*F$c&uYYMMIkNj-HE55txN1Xf1$J5(xO)9$ zd;tzmL~B7WgRn-jwFn=zVy@v4o^ikq$wu>uClOYP<_FTA-nzj;K3%Vz$3QFoKgqs~ zgB1phhHL<a;gKT``*p|6kE(VPpoxa&NWjyu5oMXPrEJ?8|C=T60} zRtE0&p~@l=Zyj&-3etTV6j-BH7|_By05>LrL3PJHPZbyfUOF0uRwhaiuD{a~sq>?b z!nuULxs9q3H~=?6O@*9^&r*kF8BT?osm5Sp)KE-hEE8;rz_xU0_K&{(35YGD0Tj-{ z=v3*>375>-;59S1N?x?hNvoG4J0|y<0IG=1TrM@tJ;^+umN6mJl6Nm^=l7+5nwED+ zj%??lSh4v`Yg~p(DLJQG$ANj$4Q>!{wWqyIv$fX4S z)-;%1VflJ0=4d<&i|T2jI}SHhd8B$hdht5Vy`2=rnJ39;>6A;|WWZC^Fc5hHU{cip z5-nMdE2^Jjn8^1aj7??o&8ddD4L^aOJmcCNC2G#4Avjt*ULlPNE|G1V4XhH*NedRd z2NIo3zr^DJA3&tQA(VMveF=P@glGLzw&&CKVyLNjEe4ZnxoY#J$07`@$UK)LheN0X z)H@~>R-DUaSfqBb*oJ@RyLb0fEWU-p(R={SR-)=2(EZqE>|z8Y?X_83$`7D zOP{_?m&W(QDi2EsV$^b8#?plgWwGci_(4FzWNCRK6zq~$d1TDGPHR33rmUir{4mRS z8oajlQc~deg(~mwnw#fV1%4Qv(}+$lin4+^C}4&~6e;6SxvOexh76ym+q0oI0~J-v zbI;2Tu+hu8DebBPQ;XGphpYxLY=;PhjVDGT5rLiuCMJsS)N6@iv?Po`R6xKqNd!|E zi{jU?C{$Ayg}{Q-)tlefJ)PuK`Woit4gy#do&~h0P#h9??@SyUrj=>DFl;DV2bpKu z#eE}hDV^Y$eEhL6n%)}_z3)wLd2q^}T?Lo&9+Z0vjh{cxk~uCqLpi^l+PgJjWL~;< zU~%id_i2QjYH571Qk{JC$cwJ-zA3X~rK6^Y#ZqGo($U8Gi2wJACnNyESRf1&0eVah z41}?0JS)HxTYJKCOcaI`g`wDJHY~?hVBZ%!YJj?}o~D!bT1*02`2dOJ@%8w85(d5F zD0VfH5B4>T_q_Kx|B{KO0@e363@X9~dKQaRhD1j!;#_d(%}tJywx3lxXB(+}BVWQ1H7F;qi-QKY{CP)w{Aa8qY!4s`$Pd-{gCTWR2q< zs_O*GTf7d{8&y>|@d#^-M+}$iY9@+^!NM{gaZW(RBFGGwkUH?FI>m~=kQ-mX03BjU z4MQNCknmePi|`hs`ZmOdDR#v391fe*M(?}PMvrw+VPdlIX-yIFO@cx;Upad@445;! zoA>xWQCP;A!rugUCC<9EE-2lYuJraZ?y8=dl^gj~H1744ITbh+*w-h)HENgE$Cci( zj=M+Hx2ccfMEkv+{R#HTtG+EAE*qWg%+7B6R=Rug;@*T~1>b|Djs+8YM++A`2JH3X z#WqLYTyQN(w9Lpz>cr!9uL%rIu6N2dWPym?QSE>1yK*SfZZat_V{^c{9@X1TT7pO> z=9YyO^0@>Lrkn!%BUm(;%QgMGB|hV?yI`XH&gkQ**V{y$|C~+N#W0yPbS4ZWB4t2J z3E%kX>3zHY-rtA2q}6!VA3uBOgZNNz2LDjAgZTn$37@T6YxC`KWqpyZjikgA6wjdlajv2Q&U@z^uyLt=o9MBnJ3(q`7w6Crzc^c^Sc>!QBRgL2Ma^TI~Xo@vb$ZPu-0 zL-{?upKG>^Y82-^xIQX0k=>P5m+rW5_w}1br}Wi<^5da}UUs-Y(sk=whkzZp0m?%@P5s3Rz4gUVC||?GXgn z1gWG5EcHK#5E+U^0URMy0oK0R&+`3o@FcO zQ?MY4dqmPwx9Ft--~^N|(Izds@!Cs;|27-8FDsbK}K^`#SEz8o=3Lvl9TjJt%Bv_2Cz?#l@ zd_U#>(&-f&vMgAuY4u1Oh=Bu$phEx?Z_Pjx+OSlV1D`ihetU9vZ~gDqHV{QcJyIiE zo`5A9EQcgTyNAX+z6k{rb5(l>E`N?jw-0g5rQGMT#K$d~?Ez0TQy*uXdsW-xKVRux z^6t~{KY@CqB|Tm4HMJErwPiQX`;5Ap^e4(1OY<%y=w5J&a_V$CU3$=CkLm~AY<<@F z!L~mGJ|^5WuCvjzmMe9Pb9Z0vbQ$`tq2$s$yd+sDnQoj(@8pvsjwSTg_!wz_n?J6J znA9Z4rLh6--(CB3FAxHwfBj~%6n;O7Y~ti8R(tCE?635kFI1Lo1QwzJxRBOU@6>sb ziZKwZ*=ciR#o;4?#w@x|QXVd8=Sw{z>Jeb`5yT=G#q@mF5}D}bpbLO%Bum3L!7MV` z?{^yp1a5#Dgkkd`NJ3Xb99?rWuX$m`{MIinb_3HjU(Y*Br57vphO&pIi=KS0`lwO! zCivHz*)qA|;G^NO%Ax3l;_m;^{1U^8>s~1r&U!dKF13y;)u-?A^=*)v)OqXXS?`QV z@r(PIpz9028uM=v>zu~wWXa@qN!SR&9u-LJPbg=!Eg4NJ6$g0?*!o9(_(ShoiJ4!+i>b<7GY|XB2{Vp{ZNnK1lZv6n z@sKGkkvHFsjcA#x^CKVztrm8st>Ugj(#3I3jnaGnJ!R|n*UfL{sEGiyBp?QxYCalw zG$~s%VZ$T!D6BsnLY#eXYJ^AsL!$APVUReYvUnM{%!Htgd{xDSFs|y*g6rUtU+Q~L zu4!Pi%~snN(hpOCDN#L>Mnw}E)2@%ZyUwLJ_k$_phX9B6EnyT4LIk`1{kRaqm7{Vn!9%f1yhD5g;5BVUL$eIL32>h`#S@FAK1~(YWhx@5t=_FWnDkGCMlEW%b6Jon;@*tVb6q$%DEG ztcK&w&DH;C$A7fr6*74Q6cOd~?NfIeDFhb>Fg9-S0@$d*hCrM>malR)(hU$@H#jEB z^E@%BC-Q&-Y)bF}j*Ud;lL>etY~b)$dqT{YEiF7(dX_dtx zo;jf!n(xPd!WQ7OSxW2W&7D6%Zo@34%D9rFFAUFPsxT3qFu+8=ya;o)SEw@99np75Z?>${8EoW5rWF21~Ytiyx-^-#%Kss*~t zuvKOi3n91NjUSBj(X^dR-DP9Pnlr@e&}XX9=&7*PqyAc___q~@eYt{O_B zd=eEvET5%|CD9oyT~gL^li?OaJh5FED`N7m_Y{Q-s%XuQ#dsZD`dYT5w% zc&kSua_WgRvae@utJ1CuX9i(f%2%<-&sn+sT<-hbdtmDKS|<%coUm0X5Jiwe^WJ>u z=xiW8I-D}$6PTiZ@J*YywDj>sy{qOh@?hO6C&Cl*8uEPJM$u?$rl=?+G}o0DcL`kgrDWkU6})O#zEy~hu@8t z`{jN)Gsjt5Iyk+{zgWS&Hm4vz=UUA}gVkN%0;PgQg;B1-w0+3ASajwKU-`s$iqYuQ z$L_DA!3Et_^hO(GyJ_WE$HAT*M`4QuW(*8cl!?g!=FZ7)JB9meBt9*1y)|k)4E0p` zyCMJSFtL>d1m46%wk{#WM)f^8|E~>!iE@#v^O2hxi5vSHwhH>HZ^ZsfRmFq#EqUFY zoo5|FRGZjw01c6S<#zxp5Sz;M_pV*5qns5(k5xDu+fKOZ5Te3Hs>64YWpJh z1;=)A)p(cvoqB`1(u;XHb7#k9>?>NJk4SND;ZK`FximNLEV;4v`NZL@1-}7GYUQz! zT?L{-d##?mN;@Wm9ZOzAd&-xj;R&XLE4Jh(Ui)7M^IIuW2V zwubvbn8YcWr0i(!aN>a#;r0s!GgWu|Hva?)1>K+HN@cMRjn;W->Q6#5dE0e&sb*5s zr0G*U4;VW9T5~zY;Eh?;L>Ij`cx_AN;qN(gHPezUCf5hNj#-yKRi@gen+$u!0yT#7 zHj@LMVkXdmZB;cDz_ik`flXBrmS+t+62<#A6_yxHq`{Wp+1R&81ewkdg=NBi4+2Z8 z%f~5WF-5S42+?eescg$$b)z_pSj^@VCD+ogc|vfOL?SIW9o5^^TUTy<6YR24UN-j= zeC>=$nO{HZKl91sW?@g4$E%9r<`gxhLmTx$b-ussLet^0kzkRr?oi zwvFCvxS=>ve@vk!qv6$f)VZaKg{o=inT@SlRG6t%87O^eh@+r)%OTc*6~p-yfC+ZPBbhS%f9f!jV=wx`bFamUo=C?%D5 zGh8-@JI57O%b(&YG!L0H*>f$ibw9E_qR(+HdC7GGr#$p&GeheS^_w4 zSPoDIK1K)JAdy6yPcQzY&BKa6CYAn}(Jmq*bnO3Ps^d@ymI7@5y#@;I5cVUG^DT{f zN4`J}#_^a`1Os6Xi6F6Ics(m54`?w(=v{jPjLq!3JOWOD_^WZMO*~R48TNqLECQd8 zB*psJFU%}DjtNr&tBlGFyR18Wyo;n;J!&2dd--lEm$$qL$QTyZa(yQ|HZ4}`e|qoe zwqnJ&k&#jl$C;8Pb9wu{igKRgyr?u@wXbbFU@T|C!gx)W}(J|#|FOv01S}Q+(;Au6-_C>Apyn3B){At%4v{n17K8ml8`aN;4jJ^-j0mL< zo%KKL9@QD~FZoaFo0p7=q7H2|uKIPbinXw65E`{c+4bY;q7^HC%utlZUI?#D9=*6o z{nEc+Eq$>V_-%U0aI#CQuQKWFZg=6 zhwGa?Tkqe(zjTy);c`Xpqv<(K{CxDA!1Dt4 z1)A;mu78V6r$W$P7pqlYJ5gpDfNQxeKNd4l#hzMU~^07|4N*3)mUmu7$<8VNd*$a{hd-@c6W!JIpcx#BgnO2rnUVDZUMILb9=Jit*yH z>LtqJ)^YRp{!168g91l()Ix~C&q!!Iwiu0|Fj+EOhx9 z2mQI~#2I08iU#-d0glb0^%%wb{mAvOqz%$%i!LTpetnvyf%>^#RXxq!A=Onk<&U}7 zKUdx_SCYtFx-=5}p3l3O^erWH|MOtJ>KP}qsHTn-5G*Ub8w=YCgg>*i{gI`Q9 z`CsmgBL8}?d(t>ceGD`LFh!K(Ak%5YmL8eWG6wUTc95wynMdp#z4SAD)tt6 zH4v+dLPKh6Cz3jR1sW#l{XyO@K#zF75%$ifhO2A z!?mBPjEsG|Og#{YAhZV^(dEX*`Hlixtq_|3`L!<>W}_VwRR?0zh6D*UT)W{Qu2s=X-$8PZ|WSkc7}V@U(>R4Z1hn= z4EyGa9~rJ!xfgxUdIwBtBq3wz-Pd;U(B7E#bZf|T@JTxAutA15GtFNo^>{uOb-KMK6 zEdX$%K?$IN2rvPDCH1A~K7NI3LPGZNXl2b7dq9p*ITz~?LN&)h;td;#eWbQ+#hBlK zFd=B`(2}Cx`ebp1P<$-Cm6dxJzWU_#hid0~%Jt2a z8GUP<$(BAQ=%0WVY$m%kW9BX6dduT$ZVoS5j8zK9s)VESpDU9SI(E;*s9Z)xA7ko)q!XUDFDUS z@EiPxeWn)Iu_3Q3PLfGf#%Z0fLs;EXyPkFx4OuT3%sq0@_ZkfgysiKs-<>wcK>V8~ z!0Dn8Hs|nwY(>pOP+R-@2IoyC*Y!T)$HFWuk{ORGR8FhKLU>-iDTZ!&bldfXZFAh7 zp8!91_-5~+4?7B-KSTy!?OZad__x!jf6r`CSK{c>tA!WS`gL9vFN&*Y`eZC-P`mxo zR=>;K-%48(=1L$~^Zjf8Lgq}>-3to|AN^eJa2_mNT>SN`X^n5jP-(%pv#rMWX1F0E zOIh+N?x)4W4Sq+@)xRm#Aw&ohIXx3m^;O-MoHjq?m>rsbITxKCG}>(A>=N*Ohlqg9 zBYjfEo1X)gpxN6syo4FXCqX7&7}^}kTOCVRG1o=%U_#3SP611~*Zg_3$*IWKxQEO|gwZz%6U#i5#;dtX+4GT_fiC0@?3 z^W=SB{=MF~t3j54KM}NIsB&Xtu+hsvsX9K(DB+whRuf^;Ccx=4l1lwak! zrC&U@o=KXnZ}S-{lx~?eDvEDD@AD-v7-q5Gegd~8_qp%w$BQ?o__dxI*}uKN6#vR6 zPm^Dn+p{;fyQ*k$N6_X`*X+Joc@<)w9 z?AQEfydtINNLc-0BZ4-;aM+*s^?l%vU%V&0& zqlnA=TU*1MFq~fbv7ehUUAe~l+kWBaW-qVsBL1t14vh@QSD&UoONATS`eubOa~j0~ z)eT=>jmJ+JxI5h%jIDZ=LRXX;f{>t@j z4|~4IT@qmCYCpw^_6>^oVdi(Ox#vpwx}F2~ww=Z)ql<4J_*j2Tu}}GBV^+`cE{bob z-{LJOWSBc1&vh+$^?7)vwbO#_s14ymrsUc=C}&8pbBwDCML|;J(Oq0quG7Lk8K)+~ zB&nw;ArIyO$=w{)?~uYZuvBUZ~qgR^)3_K^fsmRR>`?0>UZ z^?7cjVSLPe%v-6~xKX26`9N4<_3wAoE)-&z7WcRRwdH7(?)JTFy{e|UenYw_*wNwr zC*~q3X1})2p;t*_HjyA=hK2k`L0)DrtQ-8l5)NNm*l2PlP|sPqad_*N(aoP5AON+c z{3kG4RX^{fA?;s!F~xn#5w`xAHuCjz4KzCG^ybEKj|pyDkka+JsxW!U>&2=8?lZU< z6d1D zF^Ao07Fba%bjr#1i$)u#?vaOn=e_DGNzIA<_NpazJc!w7>|5NH; zzIK>$Em+|jXP+?o;-5!O*yp6z{#BbiI5qzhaAsXTXs?CMz{hvRNP;7a43wm%3!05x z`_p^3O)Kp$fS*Zk?Je0Dkp6L5J|tCvt}c_#UrmMdzj_1hC!eG#{a+`7I*#j4 zv@}fkO!U`0dNrqcq+q~!G3v}iVd9mZ&WtNPvXA zVNx2bZ9!J71D}e-#fO~`Ua4X(1ol!a#~*byngj%zcD{&qY#4P7Y%SYUw!61K!4)>Z z`{Qq~yOa31x4mYozPEPqA9BMlflJ&*lM0v3eoa9?LDPkxnU7p5XaC4j#*VM@yvc`a zT(sK1ad3;_KV0(UpGVKgW~L1v#b+MqxveEyKxiqh)o0{;zd-NW5YE(!83mtNz_(avCD zL-EBI@BY5*5?kpyqoG@=`-F+$cNgih1jJXKZnt+%8SL+_vVdxK3wDu!7FE08@UpM~ zLV(v@Ziqmlu|Q13LsL||j%7mj%8`VGgskDws_O4_TR4KDhSb3+;E|Bf8$zyg!w~;+ ziJ(|62)ilA#%?FJ)toW#*c`1s)Bd@7=Fz9dVxQVKP?M^?^;&j$>t9SNFL1hPAHO3( z*S{zwHzB;*?=r_#ctzA(b8}#x@nTUv^FlMd|dT+N8EusYjmwQ<>m!p_d)vV|eQf$&DR}d8ACn zKkThJli+3WSIxuc-hQUZ;Tt`S+oW6nC|d_*W_B^j*Tder!KilUoj4Ejs?B%XtN)!{ zsPcLsA1!^gxM{&_rpvE+7%E$0^ye|}o-XTg4adRCRe4|g(nF5wII4z4*w%TaCqa(_ zt4cr9LC3z*-DPjS158AC@0u3JA0^!E{v%Vz4i_x`_U)2Wu&SyLNOLZC^9_VP<-RM-{ZmpGB9+cQLVN9WX8eRAaj=h|xP)7XsRK~m# z9{V`lw<$2R+x4=`7Q`QOe8CLe5dS~s-lloES_J};*~tU$tiGfHehCtzuzAJ zCxuI%esf&me2^+7C}2_AdfYi^r01b9D9Gc?jdS&}PyaqR;d2S1g$SUBw!)|rw=ht4 z(751$6NJVUZX5_G?+Eyp+H$+KY~39-25P%j39FI#Zn{06AV0Yg-q#_8op(MSkC0vLmHsM}c!qq~nC6ysztp ztC7b>S)#0=wV^NJu2;g^SZB+e7TthTrS2lBkzU93Lm7F``pfou8Jv4n|M_p<=}k`M zv3={lsXpLskAInZ!qo*oSw7?XVC3@N_NW8CwJ8CnGs#22i9Zex92!czLeKs6djREX z<%_taJTwA@WAjL9 zZKTznm_oLu(Cl6>Wi#iFT*>9Zx&BLX>8Xlvjj~Onfu@PK&si7T@RbF9aNJ#cR|Y)w6@+ye9<9S;ex>un-bAX3r}=3*HItNOX$dLWdag1of6?)!iRq9ZPULtJqCZ7XI%B->aIyr(SvX7g2*)GhbYfa8sS;MCqgY6SNPI=N~ z-&4I8Je)o_-S9}hu&K1Pzpl=4y*1m-ZJ#R7jfs4&jn};)+%jqGyz1QqC+y?!^nz)b z>X^{r!jkub@6717Ni)sDAHl+JM$Z>H|L)yx0yDuRjPIZJg!@LRM9=s9F8-t$Lrt;@Ac3J4+JG%xz4<}5ahT}UdqC+ zw7>x>QyGC3^B{kk2QWaFLEW6;QBc!4Jk!=`kG7;j-XK5!scH_yYLp$|SVJaRZB^r5 z?~U@Wb2DG>-YTr9^q+R9H=aA}D17_3@1-E8JY&xJP4`RJ-aUTZ`OHJ7PDWp2>8S!Q zV&C1~1l_Y9cegvGi@*zrgLP%~ zKjl>d|1QHBm%!n`PR_^NSh?oT)S4HsA8$8|5Ukf@U`{fz2+Y3UepAsY-1n_BLHt?}saYohSx@#oXqxR0((_ zM7i!0UCwn*&6Mup0S9Va$TFUBd`ZfUvetqNbZq_TD8Z7sjq z`X1h)*I=13QE_ni^IKMhrE3|RJ-6xfP7WzyV?C2?dEFJ*A?%VuOqa>Zr4;Aszv=DoAh=ndo&kn)5O5~+twagJX zPg!rS(z-l?dcn$_gg9oX4$zIG(qQ_DhS0b&EbOS4f}^CG+@h|YqfBKn5rkp+7A$~N zZ$iLPJAUYs&hturLfBiyw#Q&X>5Be3cFR2NWbc=>Pg7`6r-woAlp> zjI8{=ggp<>mQLjK=Q~?IwDi?;*T2w5=BGXL)e@|Pd_n|vU$p(l-kMhxYr>xe>u31x zs4&WoZ2zZ%($u9gx3Pv`sw!CEc&@$^(Gi%)Xz&)Khr?jW}y`}XC^B6@c z3RR$x@{9D^<;@0-O9_YD{weD^|D!T|u*+nMdqzGgId%QO0mhn(qq}F4CCDb8Ha|No zIsP6=R5u?KQr$=A=@u)9@MWmZ#a2~Jbymd zq+Cd__}2@hgv_b!!k_1S`zKuObG6^De;Dv_ z`}XSz#5x=Iv>lE_6}&`Hb420y0!RN31M`|$Kf&%r-#t418t3(&8){(`8_I_Y8VhWp zcM*$x+CH6pnoWcge^}RZu@DT^MNsirjEXr~g2Em+xcA`j+vO=%tST-9YAp?R8mw8! zP$mLvWe&BOk!w^h-2dtcL0qVSN82M~d3dS_nk9pn`YdyyeKGLIPavG@G)Qp?ObT?~ zHgb7UNN*m#RBn35AYu3i=kLiUT+gA=lJ4g3M$(i+`x1;>>tPNW;2WUR=oRa5Pp7w` z_Dqs(9VzXVlgQrU5!L7Wui-}=Ax+4{Wi13-t%H=lO&x{nA`ggHk-ozq-&iVP;&`#P zz{mcREA{T>)Q*ome+oHyu;Rz0g}WzTcHY~h!hlYM^zw0SYH4eO@|t@` zN*eOkrgvT(<{@Dl8!k5a`%WnmhqMw2h&bgUJUEuvTO;o7Rq@pNj}DDt>(Q|o2vxy4 zM3i)tOX0E?ynDF&Cd|0u1;a~z0}vEih%WllKN+-XF63S zf@Imw1_LwiUA6)DOY@*lwMn#p$Z9(y&^uLHr{{hb7ovAd@#5|eR|3Ae9=@qj*3aph z)U4)Q`OA4#Vk8Ij-R*kSc724DzY-5=7c2OUQsdfH)|ve!HKUJ9uYL6N*KpQjma+*t zNc?h+X>unw@Yx%*z}gc7S^mtwHf)48yWvw%f$+-UrcLt!kJ}*CaTV6>9uBw_zElA~ zaa&a7l@|@)9+$8j1T0;E0~k2X5o0Oa<1_GBzCG4T%?)y5AXnB}#dCS?6OPAb(Vt77 z3&l$|^7$b%1If^I)nV>d?q6quy{ivr&3>PFH(_Gx9r0VwQsT5`;#^a)6y5Nu4A*zJJ;7@Y-SM;*17?m?pNunnT!gygDK-{?v{lCO@M0F0 z#*a{ak0sbDt1bH^YaKWgZ|R@YgXHluxR<@x<9XN< zSNM&4J)C>smir3Br(F7~iZ72+KJW{k`TAU1UmjVov*=n~sRLwrLs;tfL-jZ@+4AVh zU9GmUq%aE%=_&k89{Cs%5<%4#lOLTpGw9tP0kW}>r2)s!A;vDSh4~tag(5>XHzp3d znxV8j?+|rB9O(vsmj$GyAkmpR$W#V2fK;3?#ViB%!Bm#+$hW5C`pFLuYo=2+EM^2A z>72MYtqH^42T;J;M_L2_C=PuKNZb7LrOB*k>lD zY!Z?m`3j%LgRL6Hdi5AA1_x-%oGF~*5$hw5ZecB04yy215&)F7rxp#)b8AtNaJ&sm z#qn_LiKIJ|>pBBsXoBTQIL8w#f>?yscKlzQy#-uUTlYUaASi+&trF7GA}u93q%=c` zpn!mbfHWv2ionpQAf3`7AYE6b8|e_F5d)TG65;oRTCcam!gk$OJY{BtDe_7f<-MbRf|RxGP8{03HCR@qxekCcg4Xf9_wXosd$RUKs6a z?n!W3fAGrhvI-+v{^`I|g|l;8BV(tfNph5A`z&5)!@iI-g>FF7Tu(}KAreO#>~aY~ z^$n62ryr-PvOGQ$XONng)vnjR$me>wy55i10_a7g=y@|1zw8~Tw+j~+aO*}57SZ_ z`x>2I9%wFb&e++Vx^5+CWveNh%>a8ra-rf9n%DBwyIaI)^*mN*E+PmC&REOPWenS3 z@NTl;ex$&IU1}|*5H3ZXH!lc03T8jfdR*w3+jZam+6HAtoW$doydpBPFakPT4nlme zH_K);)6Bq8UaS&W;;SJ*MX^VZ;!9IfW#IdQ7cC%T%OQu zY$S&Di?~lTjdYF0+$o5yOE9rsjEM)siMhCW6J;kDlbBhH<3_qqI%3Q$;QL$CK=!^Tma`B^LPXiPc|4V5W%J`lV&llA0;a_15yb9CQt|* zb_U8tH*60r#R8LP0$53;G~Tgioi6|bL~yEI(b4cbJl}Cd0oEIJM2;c?i7N%WNgV=? zpI(hOS2%bKCl-1XdVb2UnxF58wtp@*XWVVP@|-*PVxg)zEBKZ^VQoJtc=g8pzh+CH zIB6L6&duzYf{#jGgXEzG6X)rfs^Uq9N$c*hR2WxDaVh+9Y-iPbDDrSLkr`EB?8^>r z{?JnaoW&Y|9`N4epQTf8HppS8@P|5nd7E7ZgrJ+3h3EW`2rxm+!Hz@*n%I7MZSbi! zJ0l2HSeOZIl?+|@U{qwR337OtK1r_o@cP_6pzm>ec!9>F`%_KHw{FSZg51##8)~R1 z6U*|;J8u@AN15F75b(&{jA3oXI?aU4a##b9)YX$zcZ~HM42$xYzR{h!>~r@>Yv1SX zrkAERSJTF9-Arw>Q7=M^&<~8+af|2@lU%e_hwLR4N|!5l8MCaS=4PKUFiNwU%7wbQ ze~N^8fE^FyZX?)5!R5N}apJ3h*+6sc(B>Du+Ef#J_kofE9E9(psYcUl+^9x&0Nw$o zZAu(v1OTmYTzd5U`jc>HN~WirZ9FA103d^m0Een<1TG*G`Inx6P0UZ;Zo4*X4I=#& zacZR=ZkOFQh4sxU_*|V_OI^<=)`hHqtS(xz5EBhcE)a^&42ycOBJNVOv&vr3r|Y4+ zoj~23xjD39HF3~0>RoxTxh*k6MNvfG7Pb=FJ~)WW`cgN8k%iKXuo%Ar@9U;TdiJCk z&*Iqk4dNQ7Mn{MD2pzIlnKoaF8;~uzpj*HY7+Cm9Q)Q5(4amL`+!lGA5o4;ea4;dI zJiV~k)pQ*-Iz4+M?gYhaMvR4*X;Dw?ZCPIs`w_kz2-%MaQfq0C$r}%8?cQr<5CSp> z*bso@BjEfTLo*>5`cuEU_`I4bJ|gXRt*(scw~Obh;H!x(9~v!-?};ysFR2mm47H*y z?ue0a?6@(1QMFB|jXf_?l~DSn-yKVi7ORt`=~d~q+Y+F?28TfA!=B!4abG;abudG} zwr1mU4~y+19f-)yCok!a)FxEnl#ynD{Ty8Y!%C3`9ix&qUfRH49|yh)$mhYv-&+>g z0bpQMCCkSee~dEu2O=^+OaL1zSeK|ONlaaqqzCI{FkPjzz|oRzEC&+{N-cnF$kvrF zvykFC4EO*>SL7RYs!k$KiCs44JQbG~?Aljp_{u`>TW%IHZGv{KRWYtG}TaT0?`sJKk%O`Wgf%tbj@(ei0lZz5V#8YX8%%43@#@f!n3CDt-_@G;=4;BL#a zz^DMo3OHQ`n=`OxP>>Da8oV&p(a`Y`W+!3?lS*86JR~avJ^-c=${Qh&tcb_9;Ls?K zS0q`xM{C3Vx{L&ia}=zl6dlZfK@bAs(f|fz(!reh{ad^XQ!73g57-Kb`Cu;*uWkFu zw(U*#kmdWYH%n=*)qdPiaA>XJLA91_@UqlKEnY@q%ne>NzsV=2Nqm%a(pe-a-M$oD zhop+VPi^Hd1l=acWdoNh`^3ApcnBOkZj}^L>x`@FJyb?feh5IE@B^nY9NgSifc8KH zTSQ0s@+bkm9XyJVHl@65ls}A;Mv*eypuL7gC596=aX~1@%a!Z4zaAcrB)|enoz_L* zfN?N(pNum?HXBz{1*@D8*&W`in7#Cp{iE10QVxstCK%*Di+C}nCd zIP&r@lp7D|;5B!~P|T=Pd@eQVqFd}4+krqky6qc&EDI^p)pwU18 zcv_zY0v~!4ag2_cxI`~0Ku;mFvniqxfWZZLF;EMUXccy8t~S>_Fq-N2VcGpwvl(}? zP&Cbc!cAW?vY1UUUb&2RXH${I^6nK&?Tr=|(68|`KIXi@MwNw+HXP7T0vGO8(`QHf z+t>x@h$@b00{3y;=+}oc>xLG@K9o4lxRkZ6_XdTEC^v*IV&J}H#|7w8M;AKz(;69}wWZK$Idx;A`XP zTHbG-7@D+edx6C)!vKchO-($U&>5DAEp_E2Mlu^L? z1%Z_!1BVJ82~}KE7o1k-y2j-<$ueAEu~A3-3-RRVx$-@IshgII{hKc39@pmXt#5i1 zUTNvvJ1pu>Hr%AAINaQw>VIf*eP80sn}m4*(K8qaUYy42QsX*aMW@yuV&TvC?KU^< z`wyp{tnPJ6>dx*=)JpzBu+iHVO2#Kkg&FIQdsVc$c}-@%-#K_V-g?@)1X&J9PSTHg z7^ICN^>EWNIHP#7{b5OpVAMwGlHo$nIIV1r@cT>G$d}0oUmgoggv0KSWMv9w@_j4~ zrWSxR5g}tMLqtdbBMIP;A=BC@IB@i22sxCns2O-#_Z5VOCYG1KubCw{6gd55v;1AJ z#M*efI?<&kVd+Qhw?!Y1rR}g5gC7Z>Y!4m2tGVpXZH~-;m!Jy|ajQI}UouU$*-Mg2 zT-y>$>e0EGTo52-de$rQ?x`nCiQ8g!~Q>Lk~a zZHiZ%)+Tlow(d?Wdz~6zKdM>rkbLG+5no0C`bcgNUIz%JHKhSa01QgmHSylCrr=oM z0!Eb{63kD?AU&TaLN-!K+lcf&hE(~Emeze7LR{R&GD?_y5wIWu=o>8jSt_&zMnJ(s z=1`*W0OkpcSqAS3mlpNTnCih$waw@|%en{D&`E~T9(7i8rlMO$QzBb*0bpOU-BOWL z#H-xZH0ZYHIob343)I&spO|1v6CDBeE)RA01rxbw6P-kl^oWtLyJMkkZmc7rRjz6Y zpPr61Zweq_H_?{>l#iqUI0R63B?PI`c)FxAfMzoQ+OaYm87LlX&vN>faH`ulp{dG(ng+=VZU+oR zfb~XzgI2*vV>0lG0OC#>3+M(&5EQBx#&44~qM{MLMzR=RBp!e!$O60{2YwC$%!imF z%3xiB2PU^fEafQ}-xRD_B`J|60tEmi&;h}h34RNUjv?zyf*S;YhJJn8>;RmnDox3N zk7pF<*Gs|)&f~r*0Di%gfeO;al?MNLG`P>Fs4yAHXQVyVq$KC|%K(=l0G0$$H*IY# zC2a&?pOrDfk{%!4D+S<`V7Q~kd~;w0Op@XUI!O*zus4y>B}2RgjLPhc3{4m|@K8ix z%muzj*_0H4Y4C7>V1cWG#oq%&KBT5>K0I88CQlUA(_mTHJkTW2ECI+$aPih;4MIbC z2>_ZCNpwY)9iRpY1GN$0`KXCYAv=oqR+m6mm-`I`0}=R#BJpHDKH?Yg8L1L7HBIpm8g92<+12DfFw3R z0-%y0>wxY8nbMYtxU?C{2QxTlt%L-M-!Gs~n4UBFALmhB#&I6bXEmGS^q&!HN z1{eebr>`vRsriC43l@)&oe(SDkqu~TtAJ4gR5A`Q0L%8Yl_t~}H{C3Yv75Y+a5MkIL*Sl~#cwk%-PB6PtuIGDvK5{pbo zkOqy!2w+jAnIdulZ4rbcgfAW;k1rEI9gedY`Eh_R23aqz>;OC|sK{l(w+%Kq{0*7L zaS<@-Ld_Cg7Vb4<0?j!OF2IaL!?wV&y^1vA1ujf^FjNdgM-sul1Os^3qQIC6T*?5r z6&If17!9l)ag@M)1|yAtwhT4K6se0#fL&SvW*c_s93X+pz)=DKK~4%^SkVB2 z8^jPeN{`X1umuRk@y7$>?g%U}A$AOlz|BSC0RB^oE_SLxA~;}G!Kmv88uSbd1%YoG zFf(8YC!K@aONI+ogrfz zFnd_bK_I^{{vr~_B^Wz#!Jz{|0p5Sm@kw%nlqqzhpAtTnRY~#3!zXLh(&oSc*A_-J zfe}C-4&Y-E1i@<#3#*D9UCUc>(;i#+;$w$qrMD~qP^*j25S;=Dm>_q<1KrdjM4V>N zW(5etw}ynrII5Zyuok}$W)`r$2$cwhWhsXy1#=UpFKnD@OH-w=Gs?CBUN1htR^pB` zKrLV*(byM>trHMxp>=07MF4&bAl_vFXfZ&e!cd3{7_Fd(L90Lp$i#$stORWj5MiJ< zB@Dj5Lc3bxSIMGyc&uIl|ZU8d2~(pU&d z4jg4lP^4%gAqO~xrYcj7QX;UDHMUoSo4NYkFq-cN| z^#$Px3yKVFArj3&3Rt)Ju-S-pZTE%oPqRc-S%#H?f>ee`6N!RNC>SE)O~Q+ieV>+r zg+2yR3#KQ~YlB_~CBsU|YJfljco7178!QT=E~zdzCK&t=p$>6)aIn}gzSvDVOa=!Y zwsZJGwV?q*R-00q;RVb%sZ=Aa1W@G^WPqlL?Kr+*pA>Q0I`~B^t`XBbKRg61K#%)f zL<+kuY&~T&W#fQBE>&xauQW*ADjCp+0vKbtAoj5P;Gnh*#EQZ(q2WGMAyqhLm&g3$~O@(H}WF!p}KciB) zvaK}CXYATMQ8xjt6Wa!nl(;Y!bNWXrkws+i0F)FZel|lI1ul%Q;O;K_m@wEvlYndh z!%lU>#m-`=BsQ4myy#h7Sg3)o{BxRI-xs9PcvQF<0JN3|+IdDLvQb-kznd1BB8E4f z!tnLcz^@k({Gzjrl9fWGU77Q>0fGs|jcIa>;MB$^jKF75G4caZM3t=XJVyk;SWyNW z3J|<*OE7A;ylH{?*vizD1Etk`lMQOtwDxwT91i@n3|+utW(QhL^WFxdFaA zm31&SILh2v3ZUS{!QT@Pz~AxkPvGI4z&nA1Kpexplaik#qo5?AV&FT)C?KtQm0ysF zSw`+QtFSDKkk*6qm*3n0G7r+u|F@qkzMTL0>2#uwOKARm z#eXGt{(Z~;!#7-m{rp0-@{i_QlBE}zba~!v5Ey)wCt=^DN9l1e+!zq|Gi1}0GzmQ; z&}FRPkr&t_xPEaVj7g%ekuCP;(@iOoB&!+n&I4(Wo^N`3=&<9r(&HJUv0L) z5$n_vD*!~+tJ32Gh|K`(QL6Z=)auXSr1;9o>J|3P=F-*N9~E-TsOF4HU+6VVfS5(ORe16L6_Ney-M+Fq02}un)I_@0NlX3Fpr@M%T5Rsix^Ade2 z@BPfI7Uk%g7Jh{K-g5LULty+!=t!OC7oy7Xh_7m0sqLcH`uOALO{?Ex>L|s?I9Jj< zE17vNi3?Ro1vJvW5WcUMekt(5YWCOJSd!{)9ip z=AXqbGoCGJ(dv_uWABgHE9Z6%%Pi+=o-cdG%vDYbcbTUtMT494B2RO1Q)m534t~&b zXVg1-Dia%LZ#e-U=8c$JKJz|j8rJsYBpa}gnh9M}*s8xMEIBnbYxeEI;s^Tu`iLI; z?&}H)gHHJlgp#N-KDQ#niuP}(FJpM8CUR_2+AeZ=#O_SZ&BXZZ3tQ+8xNYsPHQLGB zy|E&wcIT4%mAghySD; z|K6W_zsLR(HXZJT{q5hm|0^$~V(;^Jd5=qXod5rj`%fvbdHxCaa@sxl)vmpVx75|a z#blmz5N2|twwhUgw4g;t1=Z(&@Y>kPr{;>|YSDAjP}5~+o+uCD>DPwn5A6;2v#72l zsKx0#t*18WROpD6@WKCHE|eXyQ)@U@9Dr`7%e&y(=`haMV%tJjN{b~Ouw?b)@oc*K zpz)VLBoR`_5&HZvu1*O%-xtt@Ba3Z=2sf37mfY^e#H`a&TuJ zFGpZafW`Q5Y||?d4k|ZNk<~EwyN7Xy^-LKQ9~XwlvWBc?6ZdrswsbafO$yFb89R4t zv@hT6G`Bu{L~Dy|;M`cZw6xEX|yf zzWED5E^644Mp1R>ue=g z6;59WAq`+`WtO)N6;;=HAulNO3xW3nt@e$g>YJ<$StZPx9Wl=n?0>)9aXXV?MmMJ^FKgN4ug=qr>p%uIUG~or2Atp*m-?`Ac)v z31n<*Vc^JxWIk%4Ck8@(R}~VnINBWp`{l5o7xRLmYG?$K zKTl*?R2DrZSf6CP(aT>NPCRE6T`kn!qE0l=qrlNV3_O_HnyLL`l@i+CZxNv@jlI4upX zaK1;;nV8UZcvGsQ;OC8KqcBdM4&lmDE*BIBx0~&ia>L+4{@TGKm(Me2>UfJnJNnxd zyG~{CHM)NlaE@ng6!mN_Fi5lTDT6m{PU?mrVRWIny;}AFhXV)(phYg-p(;Pgj zxl5gzOC1M)$;@-;XPMb(^s{Vq06&)d=g0{Q`Wn>!ZLsv9dg{r{b(Zim%}o>!y6jxa zT#6Y6?|NU|JNU9^3?Xy#r`GG5)=CKB!h_*9{o|$*tdb1t<$k6!H5mStzQQ$ydfpzm)>rhfW>p&)g zDHUq(@+5+c$R-JOj0x}O79IUt8Nq9qLqXIve%7$3V((}D$1{aYSF)i0lxr0BwD6X^ ztjBm8^=z!1{)vArkE!a$FNFHbP!G*^i`g8aId6?%-kXuyX>U>(om-!8Y0WDceHNB$KNFC^7HWF3u8`i|Ut>7n!3&ECUDDa79uRN)w@{SlavAM~hO=XK;xl5fw zx#V#EYbP4RvKe`sIlac(%Dp-|c824&6*@k+BwebpFcuKEoOD0eqdXPJc=RRPY%JN1 zCGE03s*kyxNw&G>8!nCxT+^^a`$&=}TI5R5O!;l}Qr#`5{CpReu)^X&J+bN*LxUAA zx?ARX!0Iln8qyPMZ_zhcUZK0?gk?4An#Otg9k;@)#K7A60HtQ{wKVc<-(^OzK((L$ zGo1a24eDv+-q=7|q5u#)J3BtH`rk!~IMfq%&Qpg)=5c+t3}tf-y_Uy$5GK>hN1gj5 zPBK@hCrqJ@p4d_>TeIrGf4-CsbkQbdBWeBmxUv zxo6fjtZumvAA)6QLrZX;GV8;S6?%-_jiR%nBTjD?Ll*CJe~1kbAagHhnMuC3_Q=$8#WC@jsg`CS$-bua zP)wN~yYPs&$U)RvKU&d6p!lSqxq={?z>k2z&+A!zftnYA@)@c-p5!tgqBGhvC9<>$ zg=YQEL1)<$QwdPK0fGmsKt`tXOX8^1z5y{6D`m|uoOG;m>Bp>;I3ZH8meY$8qs}^J;p?RQH;XgztX6iY8u{1r$0PKWq7#Xo z_5A%s0#%9Q%Zc?lzfZY|^~FF{7mw#AR_DO!?}XzlD|8UWp6JBZ#bd8e9(N653A2W0 zw)eEYP%3GTEtT?tBi`;d%iQwJwN;qq7LhtT{(Wzq=zPJd1|FAB>NBjP;Q zsvrJCgiG05-jz9K$B8YVv+6{qxkdQ-2s_z4i$g-6wS5baVH}SuWgZL|uDnV1oe|4( zJLO$t-Ini=qqX+$&Nim^&BfV6#ROw~>6$WW41o-O z-hSRmS^SK42b33kSjZOz)cY@*`f>N9muNlEAZ}S(Bc85c!|TSs?k|0_x1e} zyRDrA#`tmzLw>fxto;L%h2}{smwd4yIw#_)0HY6ucZS}Cb+EeeemRzhzP`54NREcl z_PsYwTh6QInsf0Ly6Jbw=ZaM@0c4+NyY$f|@70W~WbQ2IA_+%c7QaB3zh!?y5_*!M z@q(Z{i42~QXs_3+*!>tbt&~b4zq_BG;`5V`vhvV80bD(!LciM3K^p|mzF;*}h|8bOY zm|PSwnm=UO>)JAkz3tzv^v%Gg4<*5^Tt+m>6HHiLq{Y|3QODQ7_vcTPt}V@x)Oa<_=ia}8Qa^p12aoWG$fK@?k+ULsU~>HC42+Q8>EYWdz6KX*%c zwx2%A*PR#Nue^eLrm!O6J5E~DclLG6CD&RrBBQEBTHIphN=(s$9g~A` z!ujHSLQk#vpovg3_z{SXQ90_dmW&yE6~U8jVFgE>AtCB1y_+XOI$Ju>t2Y{mYW4@e zDw+wm(J!g7F{{(1#ZJ`5ESPz}h%C_)4gZqp-^wGD#1eP@&4;n&?B={LSx4W>HS(ug z#IXayZz1}0;`DtzkGP(fpS>@)pv&^Rc-*+Shr@HrqqsCfZohk?5Xjh-%JM@%q{dR< zlwbOjg0fpVd%Q4_Av>pEPsiSR82X5BB&bAjStsxcHzjSzyV?Hpp#@qrd-S}tpNOZC zUsd$Qa#tVD-2JlQ_*Z?L&$snBUH`a5o9=SCf`}Chu~Fi`3A}0m+HNUVV17Q7Y}gRy zwyIqOQnb<0EiI|jX|y-d1+?M-~AptVMS`5+G<|L7Ld}dL1yR*unsmTN+AuT#*d#P!5(pJ|B|OczQ&5x*e^f! zopgmGKQGH8(Wx?(hU1s&^7MeWiRj@6Ioe5GS$@8Bg|p=E=c9-Gd))A6X&2gFl~SKM zyfMTn(JlNiywNDyfQqBfOITlNU^;qjU2>N|VXvOc;b|@NhxW3h5~cY^N95lV)wyQ8 z@~SSKp1z-mpDuM~?yKjOpO3AG?ufed&SZNSs?R82`pSF9Klu?I;U3zjVHNDtnBu>Q zOxo8@dem-uE$nwK#`lOM+1E~a)X!q0KYph|6q_3QgXTE%pXh(3Hvc>Gx|O%TTb`Mm zgIbda#e_rUtV8omJjU)CkeR;;9V{nIgw~GZH-`iAK<0nNGiO6vXu{y~&67e%Q6QIw6U*ysHmgg*5_^3se_H(5=xpc zKC9P7=S}THXl^XXO;4RKec~QPQ>wbo>=%o)E4tu7wysDz#~<{UXuv|H9I4BJ4Dk-t z7$tp=$@y2OHdGxOGL!d@#xy39!-Kz6Y``SNnmn&}!2PkDJ(+2(AU1#0bVK2)^}8pY ztk0u_u3f*BC;o7zSpiyK8vVsdLPJC-?v-7W%&x{D~$L3J0MMBO=I z3q#VPLk=lN@9Vso$uGk{sYAN1!6Jg8B0IwgQM9LI-DcFD{jN4At>CoRM~vpgcc@h2|G z)hS_F9Fv(?)(b34r&dX)eB9P;^utzvA9t@z=IWXE4MGtxbCd|t_M9$pNsTn9TS%Qm z)6L{eq7xUer)6vuOVQ07Q`pln53+Ec?bLCKyuKi{VR(eHe}AA7Z;#vRuSOjxXm*xb z(E3@X+AqY%tP}UAD)~3W{>t1EKQrlt{WpR248>gl4V|Jajz);8nY-0F|8DfiVM>Ql~e`sBPi9Cl3o zuhSAQUH#I;-HO5|H(Q6YCKQj3+bQxon`+nCXO4BG&_pA-b4kb4pj_^x@6mqM~O`Ay+S);&xs2 zG>B}kJNFCm-2G+8hKW?o-9p=`014Hwc9geJG3S{F!ArWx>x`a^0a6mN54tlz)o*t`C@_Fl&5oeU$nadAi~A z3?Zcfvrtpj?cSeV?~C588%>3pX>1gW>)5Ic=9`w`i%keTdlK+nkWb5@D=9g^>Yn;p z%o_#OJCWXNQnPu^iDSiOFO%k7h4yU1Q?+`~%9m(x%eIYQkbba_%fefabD$z45>AVW zJ$VheW$pM@A?*L$rKAu2acrp{dKR1Xw{+k?){HA}KBNs3HFm6= zcK}sJ3s;yTZQ;M8`u{TunUD1!VgE_V;!J>RtY?v;y7M|nLF|nhIpeLJ7(4s*;!^X} zjL`kDXR(@(-^YTSuF(IFLsrU#`xk=qP4W}B;uiabnHMsXvkGS}2wEpyJc<6KeyL)- zGOaz0@9+a7U$2Kg0#%;4Kvv65NR*Y)AN?|IW^-`8S8dRDKE z&D`oTUoQySoJ8H{%{i+?yReuM*X$<5{idU};Tn%)NSkEZ{M-&*#PC7`zM1la-HN*2 zjFbmN8kx^Unhu3zcK`bj_bh*gUILB&dIV?H59F!uB#* zCVzvX$+alFU4}pl2TjwUuWqCVOi?2B_mSP?GZ?OnPbZPVY18%e>k5Xei=Vp*n;s-^ zzCm+D1-NGTUlPw5P}OHunYZbj$SrY~nF#c`Mo1*8gd;5F+g$1dxq~O^5u9B;FU(DPf z`#Dm2)@Vb6bG<{z1U2hV9@-;*$z87JRFZ~7lAFX@Kw^)0>Y}s6caP7TRf|u$7Zbz9 zlUyM>0Z}K&c1}FWU5@hQ6W&{Q!-wkI*(%bS+EE_bO=lXt78&c#2Ilk8ljhh2aq1=Z ziF94Pg!Vrt{Fg_`iLM7pHcvN=PsM$f7(Ts_tsx}qQApmUc?s>iY0MTkEis6*P^lsG z9-A}b5;iAWyv+;uXRlYzKKZ)5Us-yP``-Jk`sH7UYR4ysjm$6^_2H2ckvhGtxW^0nXU^PmPVXMmED_EO-KCC;HW?@qE}eui+5EZ=&=`}hDFoAmGFH~-(6;U;%(oTPiOZ9Kd;JSxAlWzwwOnn!0& z^DIZ$?9jtTr$~kB15Q?t_P4VWF0u5c9aWY|Cha-%wYf<*+mnosk6Z0Yw>mBvcU*!q zmN<@LX&{ad;|`(^vF07v!>OZ6%iWFB!->=E=fepR=D3zo)DH)%T~q^6!5hXNYW<`uuIwb{R&s&brtgN3So2fL5LHPu^=kYZ2|3WNVl#G2z zdti!VIqCc_9CCPTXkt<6z*oUVKKc^A1boc) z{J0#-_-i#Z5d$;S`s9lIAM_U{^J1o|WrN#CihuAwv^Quhp|8Yi9EHBZ9@)14W z$KnK3R7vxO4(5@)VuuP|XwV+@9!cKDjFHU}to;Wb#nYg=XU}1-BwDn5U%4>ik+e|I z8QPko_1Wjyi=RGrN2)R?>bSg&Me?b4-npRs59r*D<48*uKnlVf{pXPTn=&nii3+*Z z|Lv`yMqh9L))w;*?VgRi(?3NX(K;s7>dn)yF}!J;e;9lU6Ux&KA_2%wfRy*6V|$+d3tf9I5xru>N+6{lUGM>SmFkAm0(OR zr=@l4!v!|Z>{^b;5(R$PBk%8X)?+0E8}HoB?qt$fXp zQj;Why0~9lbuY7Xj^X8{Fg4BH=L;NqFU@_tTN3vkPc6~GDVy%-@dDu~pA@&fQ^4N8 z1m8X_iAUI!{Y(DF{x>JarCjK-Zw&fQ&B)KFgeuRQZIX@DfA=2VR0;CFA`B>i#qh2S zsg?0}Mo}o=45*9W*=`4^rPMrI=o2N5&I!%R&~s$ zcVJc{XkF83eMF>0IWwv&UN4@EGlA%PclRCUR;xQ0b|wog_C;p$g+;F61x0$^E>Hbs zHV|JT4h6UFen*uO2YsCV>0MS^&~D#ip3<7p&L&LL7A zC}{N)I}N9u)xsl@kFEozdz~=6h(F9 zMz(Zpn#vx^V^#C#`!!UMa-8|&F^Ji@glMsb^lKe;YPoXcq z_GR_2I%SpRI=Kj;OE_Wmw4tK=;yJ0IejIl=wca$Q$GC#T;j(Fn)kKvP3lHC`&LYye zcsZJP#zdjh7exC_Pt{%U%gtTRaM(B){@9=;MR!CI{3UB%i21mrL*(C+$qc(bOn|6o z4=O|`>F<4-XGu>F>Abk6o7w>KHt_Z3{Cm`)r1B4XmU|lI0m3kf(TT$(MCHfg#*Lmu zZi{GD>!F$$e|J@$=5n8{pTsmx4~8V)uJ5@^9;7YjZ8C?)EYxQehRMaYh=*Cxc8exB zU0ZPIp8XwN6P<9XT!d)ipHzr0cywfnNFF}g^MT6$ zmbv{C1@mkQ!pP zX5}8Y;1ftcHgBoHnj0`zU-@!mElx9cF|gv=P!m~jOt&guaa_`?H}JL^O`(f z60fIw{4~Gp=S4-3#{Moq+#vZ{BFhtI9ouFMXvZ1++!>Od?!VRvjyXzr|1`PxBgb_) z6Q^Ld)|u1gx!+<7Sz4`}{Yp9ColvKpZmW#`!4cM^*EoFt>il3&_M39{@^o`@S;chZ zZlL0&WTC^;Qf?y$fD}e|E_s~ob+k?WJw~h9<^9^pp|MT#VE;F>xMkt?VQz%ov+$zo( z7gbW<_#oAYYc%f`k2+n}O(GSs_3KB0chX(0 zZyw}zb74QC#3z;hkdfjG^50^~>n*}|e{-4t$q>u@o4cm;?Qe47n4Al-9jrsKxGkf8 zc!03_^x(2f!q58{5}%6!yVU5wr#vh92cPYW$rS``h_xGi`r)0W1ubdNCr@xD-1>#6 zxSDl2@(t!rNqkdPOr&$Sd4X69%7j{Y^-9Tu@v8$Vp=HyhwA~*G4Tz{HSz4`!(uAsi zHeqWIRGAOoejlDsD-_1$jI$?}1pVhUwv<@-zoT>|L1#J3K6TMgu(h_BXXV7S8adXO zUi|LDt@7m@FG?KWykjt&cSxenU}M|7qb!_P{|`CXbLB4&*ApEPmu~rPd_5I&TcQSM ze&pO0X)Nf8zROdfCFLtIQDnyZ|?Yue^Mde|Cit=!bhohEVn9V<~yEI6VY_5T)Y6* zq_!I`1C^S8az2-u<9CjpnDvB7jr`ldtfbiGkR%@R=x6~^pP!Xe0S9t6yY7W2-~CnS z^%Qe$B*Y3-L*(1!D{NS!xjZnM8}f;{`MpQ|=6PzViNrC!rpp_8{;wS=nW@pD%?ceh z?^uF^6uBkVZes^Djy>wbv~aCA^_sKu8wuwWo9{S3LjZdX1QbRuhb~S>K#C4V#w+j%*jXHoW>NGc6v$X`l>F9 zqLGij>81SolSz9$KZa(4IPW~~YHv~O#wO1}S@HtvfUBD++&*YymG!Xk7oxaPW3c#I z;i{6-fQ`S$!vUAnC}s9FlN#1Grd=l3frBX?Z@-KZsq>7cs!@27ts><5=0mOOz;_q! z?RD*aSxU0h4^Qm1Fyszwan}&I9cnV)-f<+J(c=7S?Rnxy^XeOJ%3+J5pDzDl(G8hb zce)vdt?&UW|2P-jN9{s6uY>SIw?`cMOIcY(c|C1Q(FddrFV-H=b)TsE%k3KRBF-nS z#E(@IpT#pyITZrq3l7k)o&J@0qFSFh@;Tq7o{F-A_6C2CW`_)8!w0d>4s1W9GQx-i z^FGw+xes2~QvIrUIdko4d1Rm!zfI(u_6M_{71BT`V-No9$BXff&TJ^GC*-Yq$35|! z^;**8)uS(U1W#`6aBTPYJ*>$`9t!2$C@H%&?MRt~kLK~{9Z48{HTG&08-V&>;UC^> zJyc?m+e*z&4m@L;Ue$VCPWdlHrtC2PWuf;`LgtU+W_7PCWZTTjb`9pQT|QgODd}nU zBc_SOMt+~_&>1TP>MvDn-J+rUR!aJJC_Y_$8cP?GVySY@Q(_eG#q_+-WDPIgY9HwB z%M7#*Xze4`6_itm6YM)nvP;bv{%G`WfkwGo$xCm{Ll*A|y9ec~PEL_hj>0wX^kr4G z+vgqXI3VyHpMQUY2?#bcKnO2p30!FScK_If!{5>7+w-jPjZwmZ&|*t6 zBr?JbdO7@HF}4*2^kV%3rEkKM)~$JlFeU4L{} z;h;u+=|TH@Z%U28t!m?Kn;>R&ok;l*jG)G=zI0tAsXWxbJgiijD3C&jbZWmpNYk@w zNG)BbVD^17IX$rm{xbIztF(&RnbSL=du=}#vBjY}g8zidZvi)#-c4J`&JxFEohVC>NkUS3jTG= zmQAkHKkuc6Sx>f1wSbXRRe0o=w4V%v)D3*OLVwDtMIonk{@wWxI)6T<>Tuh*xPnxoiTNrMEGX+xUW??bH@Bbnq1*MsUlm0>?HLeE&^rEneq#HW z6E)WTDXmgJ+~ZwkS(^o2WTYg@XM%04VudYj-Y!#;to*v}%fjdH2k7KEKcv2O)(gY* z^^J*fDI%?xRuk#c)82xit&r(C0Z@h>=IZTnEWM16tja>##76|hJCxNEZL(slO$yvN zlLZz$yC`;(1>|HRRRyXp49x#uoV^8H6x|m$4uZ6Rf^;L@-Ccsf(k&g*CEeX1lF~?r zbP7m!r?hl;BjGy>`aI9C{{Q#$?#!Nh&iBN5I_QqT@ilyR*k2dhNDUHtIliXJ< z53kn`2qFi{%Le=yNIV067=VujKxAY@Z9*S@6aZqr{AV850L=$g(4(PhJbfa9TNt{cN(wn?#y6C3ZcJy%mNPR`k;(_E~=;l zt(XX8_INUNq6>f-U^I~ii5oB58dW1*cyDB`ZGw<5lKPFifkA2aGyfjFDZ-ALCydi! zp6%llM`bIz7!Gi>A1VRF2af({&I#bP!Nzt3PA4|FA`RrV>=8cK+4o~4Bf6xvy0c!V z3{;==WVMJ6-U#|Ra-(Am)OW294nvcxZf8DO0{lNiFGztGqv7p3(Z78>X z;yZWJrn1_Od8&;4BLr38{SWEUKvC-t#EAdL4y-BMR0eC5?F)+?lp_oOVGKY(29de- zld2el9_YRu&qAKNCT#+Nic*yg0CTp;Lh#t(KM>eldFXe9-$8-$fT2lXj2AecBK(J- z;Cvn-HE^syjzFw`wE~7FPk|vdhuI!UkKt>PC#3j8Up|O^KmOR!o45tcUJKktkaTluods(jss`o2@5n~m?#pk6LT((fxw-gXAHRAb^y>Kv z@HrVWNCeIm_=F9%r_5u>n~+sakE@=?(+hmeA;5`u$~rKxl^2X4jra=xnLg|U=YHIX zOrM(CEG?A{gHOy)-fV~Nm13sI!)7{4Jj%Kyx0Uq%-Fq4CR}UL2H!C-JDh@uHY)GDX zkob6(DPWQWsL4~okSG|)1UA+!wfEB8><^X^dE(=rAK|L4+4q(Jj0<)j|E%}h83Ty- z+v;zNh&<8uEWOc3fE=7-5CE8tvHleU?2>IdMm$FRU=_hF$}J9rjs{Bh8eEWn#(F5i zzXA_M1w+3}51miu0UahHBLF`z?htrpBG+D^H<>w^sy<2m$=(0cZed$7+?psZBG!U3 zk@DqhIW^o&{a8DaPg>91o~iV@1%6qLGs=0E+ml57nqBA>2lR(|;H+>wYqy!l5Hb>Z zzsM0JlQ%eKNNnB9!K~sL$BVIhy_qRaIf{)hb==|a`b>Ub61Pt^;Zxyv4Fe-zweQc3 zbRtViBolM6q~uku7okMg)y@$X>1m49 zUnzK3pVffqUyd5X=J63c(2Z+CB4x)4kwtOAvLkKR5vopXZ>rbRI(yzx<*my`M?ZRK zg;Hp-SxPnVaMyT}%F%4aOniTAJ>(oz%j{hvx;1qqWJN->B6(j5Gd86N%)SOu_&QlzKmX>rWZ4no#izK zB~=Y|+{H&TwHYtBBelG+b|uWYzGJV|^@Lv#tLqJftNro*%MHRd13#`Q5I&aEe_7h zcfko2%y*pcj$ehY8?yvic2a}PxQAZ=12kg;g5A8%5y}f`DQTayE$ca#Q&rIlx6wis znMuk^Zma!*Xd7Z7Q!x5iN!Qn*l$I-1EVE{BABTE45wyAUPBJn2hos@KZ1bEI8kvzp zIR@mum7k4Skg-T*=B(U4rDJ?ZC>jPi6?7-@><4KB(XPxnIcYoi2$341dEI&akO-T^ zPBc7Uxo|04qPWi@mce$_0zYfx@<^ZM4+cjjk!A;PEn~z|^Pq_VvsG=d^4xNvFn$=X znAXl^1n(c?e`CoA-y-FP8}cOu7=3Om*-*64{@cXdYO-l)dHyrObKz^IpS|wBf+vZ~ z1Czv|AfLd&!@)sA!Gb4=Lqa@(f_@(Tf|yB2{$tE2DiH~zoPwTh=2J9gL19IoWpq*z zeY?-)tt`5}6|a=CSPg9K{o0ORik6MB#g4E1o;nUM08Aa<$g9?DH>=k8qH+YhGn5M# z5tR!k5UMqNREi#%FJNZU?CBFgEl2&EQO-w4c>2T#j7%Ur1&lM~C;{Zr3$Otr%K0xJ z0t3v0v0#yM^8g!CAaNc|q&G{PY!B&W3=&8Bb`NG;p;tzrr;m$DtyyD{X0^7;m)TP4 zseRQrbM~;l>IUU?jfKLK9IFX17<%%>YPytK1z>XiVvNCvMpZf}ulg z`Qo|e2l?iEEDihNVq>Oyt>^hV!eGN?DW!_#k0#YAFqz!{!~HEjVE>IMgN+}^-`c;i zzq!f%wZ)>si^Jb!2h8mPWhmOYII&hfcXK*?B$JPv3rFEjQ^RH9&X)($m^|sAy>RnL zEL8yRfjB&Ji!wW3i>&T;YW^4bS_){tmRggazg4Y`as5&V9EX39BCz-uDFJf;=>^vd zT<990fRma)0Vg%V#rj8yfQ)PY85Qhe3AmVnU8=z@59DvZ*(RVSmO4Nq5c#>L@?%kY zn^eGuPDL3w_7B8G{7dt>C-&%F_H>dTI&`^ZcPp2^4 zYYobg5}r-NqF(1$a)!NW2s+41;Q_r0i$;m)>gr+L;zogbld=M(!Tiwd-Mtnt+K2P~j4d{M7a%Ac180@(vA_aGrRXu!#X(&3E zHt+s|D+;?U&sSussdD1tXIlmXt2?3;RzR^jouLpIrjms2R)TpqF(Zi<@ z#Lb^RDEj^||6!r>XpvBW?Hc`u0Ip512aQM>9s)h$ZVU>{=cEiEqaE?ZY=D5>Vga-M ziSR}$z!#kNgNOQK3O0W5029FS(FiEVf0Rr++qeRpbulYt9_O8n*X$SANp zWCE`7L&g4C&c{jsr2z^C#5n>7)qW&EHjkwQiZk^m2EYOtJRYT1gC+1G=Ukeq(pa&F z_+rEZB1^UUPHg%LCgN48^s39g>^h-1Q$(kS2oKZOGvHgp{K?;b3x8{N#gd!P0Q2hF z%W!jaOaPaZuezGzzw~u0b{>%TFi2N^|hL2+BA07I0v!@{B45g!;|4e|DHNa|U4Ikj)IR8xm z{Tr-w{J(?!$pJ8V4EV9R<_o(|_ET9U1xWCE-j{0|QBk4aawcq5L ze?8_H6i|<+_*NKD_v78qY4HU0ZH{UOll!3*-rzkJ#|9QpJg8_G@_Uv#8bW^iB>wxw zj+M)XF`Lv~$R;1#)l?zA}WL`X+(dc+r3@#+g+ACOooLK-J&PF3xl*`qr-}3gUyC?7Fgv z8(tCd)z{(4*WFI29=cu_C6e0j>#w~)DkH8u!u#8L{oXxWL*eX8>BtF%5kWEY|A_@HR}~bmoMKMpM4*1K3r?U725FFKDUI;x^dO4 z&7sD@pTNu0dP#BKUq{`&l&+T&e?=Y@mEVQ)zV<~CGowdJ-6YSFla^glwnfC)cq=)y zWn(H-HKvG!H3>}mo9bCb5eYbC{KD_8`|VlP1=G?toda&;ZFKC@Xtz(iyg^6s_mGlH z54?gi^e+gAG%G~0j8hyGiHozEn2`g_Q{WMzZgt0t_Eh{~m2CJUBXwlmbrzV}k@0uX$y){TYY$$B|9tiX9oSHl zSHN2mgAAtssB3?sJq`-$^b_+WWMO9+Z_0AVILr-sPooC{H}!}6jmh0VDp0^CsQ<2& zcg`<&M7JfduMP7T>=W#W$XHu8b@-$tevVcupFAOc{=7-l8eoh8M%-E+_YcAiR_y+f zZ~;9w*zg~WY?I+;gX;^qg=c-6PEQd{S9hCn;|xy-J45sq3-f|6(7Prjrm#}d!(FBM zKdcHMD1Ib2u$@mmGqb<2=yU5MB-D6Ev<|KDyv6G)7mDpbl74g^mLplD>QB16o7fVY zog2#d=oDPEqa+z$kyu9_Kf3tHql);|Lp(264^5#Jqk$~C?(md({t+1)z8>xspSs+y zD@nVd=G9Vv?*>M4_if!Q>gDXw})<47lt0L zAW+w5pEc^ef(W7-*G^dv#y>dPrf=k0NklEE<150jpEpP#`e}@s+Ok8GbIbx4+s``0 zUD|t|jf-+;uLxok8*g4egEoPZur`5Hx4^h%vT;M>wY_`%OuJ-&2Q>W09wd-`_R@bX zBGm-Xhx40DM0UzNg?CoV9{dV>*YZz#>0(aiN|A47(ik^%7b)){LIOV!h!U;ub4y_$ zdmHHBc1D_Fg<{FiMU`zi?XAHPt-^9Z^vzlG63h?tJ{-fX`^EVEEm8|~-n7e>cV7Q4~rk?ZPg&A|okr=AyQHd=h-cs)lPL+tPR4MXP`tI+o-(!~Xg`~>k~AfeTS zhJq${? z2N1*yaDHRpM7#iZ$D>gd@x5k;4gUIHHw%BR$^8?>ja2H+f87B9HxWnRTN?0PgzxW* zdvD7nq1u=$v^Pn}P#vw+r}cxYqlO(KhxCwDsuiB6duLkZJfz7PC5pqJ7busd?Hydv zVV}?vt=2ZN+|nLgRnMaAw+OV4r-6QW^(S8D`M>B%+v6V~4$fmgN||eOvxeVqq^LPc zThL8=kOmtm*pD9B2Sv)<1!phkQ3w0jnqazI+A+RlhdoXs7~7B!d8lN16Ef&9yC?i>x}bqgXFO zV(-nn3owA}i7XOg!n^^0d^a65`d1qT8IU7S9{5;@L1$UEkG4)0^ zJ-mBR-CUX;FGCqkunj})S8v-ysP$?GdDpu48p`q)kc!U|otfPv1clqg+8H;~Mi+(i z!_=kP@7@qb@&DlBP0R{6VlOR8T(uM^C-elKk!Xnb&Oc%>+X;Ph@><}_e&=Ee-giTq z+1|zG5Xe?1M=OCQUM``Uj&7~Q-57j3gA!?n-pG2U??i&8wS*iQ$IKg`HjGs~F5l?y z1%$5Amw!myWq}SL=6z)G+ihx;Zsl(MR71NOFii*3svp;zWVxJC|I)$H(+MOTDgl>nOhz1 zP3+93V?vK=jP9`#oyn3dLPbg9JuPG7_gH-#E@oOva%}yWk1hiyq?*~80h;WLC^M`Z zhZ}-KVQ^d{o{QOsVmx15r1j?z?%~v;r688ow?|71d#-Q)T%T49n6wD&VH~9wXZR6? zH)@r@bi1E2?-k0Xw-$o^T2!;|U+WOuTan5u-L{juO{B{d^!r^%Uzx~2AbIQKIg13J z8Dga^u7nlwrYGrg*?PWO+TbC^8@J5)?1)6iKcD1@H;rr_;BN{S17e^~?dDie@1j57 zuKaLt-1SiSsQqoLMgOQL=3Fe5XkF(%I&cF63R+4mg8L|z>4mj0Okv4q_lsV}9hW1)IR+7UCr(V|Rx=fRkuft+)JUA@0#>W?8=b|peq+k_4BhU!E)q(6 zj$Kuni-bK*E-DjEX5AgTRe#a^pRs?$U zeB0<9nPr(RhmwQdyn#dngcMdG-jON}`C3dP1;Gy>I z$<1F{{@pOQ@3SmgKVilT~p`H5We>2{KG^hw*#2ZirKh=M*L4|{U z0;%RLNQ8>V{^)>=`!d*8P=$sQLWQ_pzZ|j^976~k22~L71I!_S0H_E<&nPmRaE#fB zkNO7*FG2)uMa9)tHy5)sIe~v z%~3eQXez$JmS>o(x}kkno{Fu1Put=>#F-c*cjs?sKWm~k-tr+v zN6ic$^6h2ig$+$$tdkPy9%@Tl zvJ|h$AkGua^;XO3tX)cD3n;;^8m>w#s@X>T!_{eEE@p7P2 z&ekKT(3>rx-fQb?(W{YYC(Ok(yzCXw(LXn}{f#p(=50QNJ+uu(4L6;zl9%WP&Psz1@?8K`q%Udh%s|^&h@-nsixRmO?Z-J1RKk0IB&z2fOQ$i?bv;p zYz*JY)tn}7?YWd*@^7?l~yd|aEMhp(7Vrt7WHN~SNgs3q?& z1Pc$yygwk?S$r+1*vaj+v#6zIpGyhXuFgKAS1(j!v;6|~X(#T`_|7Gz*jEC@PsMBD z-BLkf8-G92(%Tq6QunNP_xZC;{l6f@a(lSl1Y&`-vVNg6kk{SaZ=_4-4eV9nWG9CdBu zXuT$_7QBf?Q97(w{I(8tAjc0Te1hjt6Da?Z(BtQk3_zTxQe?WG2wZGby^QA$N&ZP- zGU|p1>VAPC2^|^He#kiOB}@4W!n`zk@0J!ay@Ef2HSeT&DV+ePn5z4Jk$Ce}o#9m} zDOdX!)*5H#$+cNTR*myDTi=bD0O-W-);{Y}?}SdYYlapsu^wE zdTqgChrj{KEas|Pb+c^Xqm)6BKqv8PW_tKFaEVw=b8@w7sI9E@BL$_!TrBO}hw)k- zt+dO-@R4SpF;q&+9BiC)vlv5_&&Lc(7*WJo#xJ0`8C;40^L4sH!@rq@ zK-;pKFt9^ZD6~wWO*vH^hG@qg7>cY|-X&Dx3On+_C7ReX=B3YuME1Z0y$p}(qoAVT z5;LTMnPU!8Q#vp#Yes;dwXnxyHbCgvQ9FGmmKKz1>=mi4OWa~%Xi0Cohq%9RZXf~+ zt5j&JetIP_VPL7I{MNth(%{UEdHEk$`h1?*ufZf;>} zYk8SxUZs>Hc1TS>g|@`1eI1hsSLdb904KkFsCpoK=fY*PSa`bREC`Kt9^~pFTZ5x} z+SAUtFwY#P`chhDTC3eMNNB{F^}SSF7?g*)Iskz2U_Q`;wpei_grv zqCYY%q(UIm(%)lLEwfhn(m~^OP`P*UxOc>VW!UBtJ`t^e+;8Ju;;tzrQ&}BHtEE&I zdVZ0^392mjyOen!rx5j}AOa?FZPW|pHS%2W@J>{>tUZs)MCbK2R@4@Nq%9+?+5UIUc^3yNPqXI^U` z?&GUmK#x%kLfwCa%?ggzMxoEqzNv2zsk?CkZ5c9<)95lq`BJH+2U05`QM+Gzj^dydLSE(2(baza^*Gp`#P7 zSP|1o*O<@Kk7etENLnDr!%>JmI%1ce)G32vZMC9zH$(JymfGs+OEBv?ssaYt>{?k+ zQE9|CwtU;?@apLDRrQB1t>%J5lA!nK^@MMuPrUkfBIk=XqzH_YIMn$RgGl&Wr@Zt- zTWott%fG2Qq&c(NQnfEm%>?*shAkBcOn9ErLZL3%K2cGBH;^v7oVXlq?fa8`#9+0oOWN}@fyJ)=%!tf_2<*xjApf;7S5o(>hw{WV%`gc!TLn zOe6~3Xe+~>8Xso544JTZX!^6z(=|Tq_!-iGKssA3K`>enZ7Cc{2LNVhB(ni{nsQkW zoPSSErUb?axkMFw^uBN;U%q^2l^Hncu10(s5xfk6@2};dD7mFwcE>du0y`nUX6T2z zaxk{o$wD47LomMdo?u8fu=?|uWEtday;aZ8HZHnb+)-v3vYZnh>{sg=S^{1(rV?Rs zCsimx=qEYs$OjO_?WD|7;;f2a)ZWLy_nq930az-Oo@ z+PF$PqF#~qncL3Iv^gWcYP(vgsy&>E&4c0LK|w6OsUImr5obhye5cMLLSZ-1Rl6@1_7Z4vGVTT2>7kzWg_g~ zffN-Gy*9X|FIQEu)XFW0Iesk_>~4D;QZa8?jRzZE*KCm~)s51HHdp+dnRCO6&BFGk zJoYk|t)^uOVlYmkZqs1eRnSWZQg0vG}4JuPks07(#^-%T5u5NS`6| z0_J=Cf~f9E;Oj`>>l%|Bk{rr(KS#sGMuVlA3;b9{-I%v`Q|Q%t`Rx){f|U~%LI@cm z2tp(j^ zB$Y@nvRUt~Z%WZ>#Mh>XN##MLYcKC6h?F{^P5Mae`$mXV!UUrFxa*DEq@|F+Dc(yX z*~z|&JhS~BfUK)4Ry&>p#ICqtwq8YPU#p1Pv1IW#VgHN!8*?mCj^j}UI-bD<;HYO) zi)st|zy!<{q_bLu3}Z>aRYfik_WT41-@Ny0`MD(I#y0xQ!0)EG%EH3zV?1| zp1^lr;PjW47JcxLp+TX%+jP*J&26jr!3Je3fy$L6;GXCGHM#eT7k%M?b>&R3$zjh+ z9^HUXfvNqcP+ULC_0W~K z^~(ft)6duGN6R)()54o$*;X36vR0~a{^y%oo%bbedYnS#_ie*Ndmbcathe$6j#VJ8<*)gF@?kn#f>U=9f)5_ZIcyL~<#WjHl}LLSHWCT;{Udm`{Vx zzO_raQfG16cTY5H=z&12@WeFRPO1~r>@LH0(&OL3dIN<{j@{CBd-v(Pcw-6 zLZ?o${AlF}Z_ax9k*j-; zsaBY00(6ZPPEprne06#ui;YER1|yQwDTLpTfEgZXDq7#N)akR`w-4dDy=WXAS%V{< z_QnmQzR-wzy&VxS^9jv$46oHIxHP)?t>*<4^1v=j84C=Y{~QViUW5P)87ClQh1EZ| zBYtj2vL@CO7L7?luR`-2?tu~23a(eTBU%4vJ4&%A4Pfx6afM_;)2_8%cRtKYje{09 z+N@l+7&lr2_^KmEe1Dmh-Lwp@iXA`mq$yutPO+o0Ul2uW)}_#>LV6`d0>vsf4<-qb z#rq2lo(=n+fH3O&2<^zeLjB$HJjlj9k(M}rFtzl=c zY6ppz%V|c_LLJkM>vRmYz83HEF0A#j&NfwQq9e=KjG`BS2indn;>=?Nu3)IKB6n^} zUv!Nws)$#yAGk>rM(IT7zHVZNifFn6|J}Ma`GP0iFYtMhXe<3O_zy)%U!3{QHI286 z#lqijtog$QH+epEA~HVx_UhteBylQxkA9V+-#!md{T-eZB(F2X@=}7q6~im2JLJWH z#DFXb7?p4-Q?}SK^Ov+qed=tX@iIoNNqr9pRrZvkWN~hcx(wOQA3&kESgFDs{gSVg z9o6>M!#GK@afy8NgL*P|j$7khW0-9ve%*vs6kOVy22LBbu8&VYk69>i$Wh_uqeo02 zi0?clb$Al})#vq~V776F0*f(vIDnb~BNv`TCq1B6844W#i5h2stTI3VH6EbC&PSX1 z$8MvAr?Jbd!D>GX68;4dmvE-Rncf!Gapu(MbWxJOQv0lHm~)WbkeF$NWtL)`Zeo~lynmFo0;T2 z^*{elSi+!c&iae)5@-w6HpOvmh3hy0_qEj)0XR3`O#r~zd_zIrejDCi=REWSlA!Mi5bjdVARS`~{iNsK-JaozSSiC$9XR2(kT_7wd;a zMFc%*ssP4Z^tMDrDN`A$9Dp4IP%z7y8{18eK9;12U=RcBc!_I(_>Udj1p+ufm~k=n zQvqMA&8Nn3zaUCcfXQm(i9PrxA~*F?D2}wPF3g#Cbvj!wiYoih&kuQ!Bm;My2ct)QB5H|-g2zoYqPc2qJws#Br#bw zkU(kT|1!C?j|lGO!JR4c+HIe-CZvr-_Hr~zd@nH5a=Hv|VfdQ&7|4vYQd)s?J8`RsK1;vgADXVAooOVxft>r6D68rli5@Nfw6|r5UjnONM*0c*0S{B{dRAn`;%2 zquA@_5@LFJg>5w(WnihCtCUh;6f}`xOtG@Ed`+eEc6?LAwSYc*5 zs`Kl4UWvGC$Qrj@5PN{nfhrKek6li^Bh3ZtOQ4ib1}Gf23FFRy%F`|{jnC4 z@DcS)wUje9@|8-IGll3DlL+i)0k~|!5lNZigK}BM`Hu=dliJL$V=R};XP-U!S6lav zl9A#WJ@4U+*w4h>h1?ixy9v=28z#Rt5zdV*G$ApK4|U%nlT_JwAxMki3Uwi;t?#oE zpjp^nrEGrooU*kB8jS(##{PiqwzFkdnQ=%o|}S@_?QHZYP9w7+Fhn&*+aq=DHT zlz;`a8`>EY%On$F6j^Y|6yZjRu3EB+pN55j>*gFEPN62n^;QqZSthZi`k~n#XypfX zIH2j@mA;Qp2RQr22#QvkEX1*zZ6~Y{777At1%$;)QH=$JE(-kTq=(cJyD!0X@d5kO zDVJr1EV8@M^eoyV<)E@|6;Jwg%&5BWSz+4SBdj1y@HszznvuIYp5U*6z(9z8%^>S) ze~Xrua(0>5#J7D2^73`N1@70*O}RluTW=c`_6SO!UlDCoOj6cM(JO?-eqN^pI>mqv zvd1QBK&G)$r~!ytZ=Z)-ZDuz~2_B{=`Fl@}VIs3Lecg9gw!oMgt5S(~Z=2}mMpkAh z=gF;Y&KfanN85L{yP&;-kDLCfpW@3Y!4L2V4}z-BBW}nNEB&xk<&@d?WCl4QrO_Jb zJ}K(JhzW)sVXS!G9#yWZRYtFdNsAc&wExMvk6lYW7!dA8(H!kEFLRv-MmnXFgq>1G z>@d81-Xym2nIpdu7?kq7iM3|_+7+etGw#+5(%oY3*62BFS5Je^$?DDc&Vn{^`5=3M z?lbmjmZKFe;iwMEh-Fc01X5@DmjFa2NepSNVq5GL;K&_L{y5{etM{MvJ{ea4sY} z%gTkuL;fDYm+~WbzKYgQ0bYje(2uSt7eVCr|3zU7fm z(3(Y$Qc%9sL61$D#O7iXe)rl7O1A0Q*w9%i_<29)Htv^Xe=6~Grc2+)^hkmIpW`k6 z9(nmcIS+hlaqS$V9hLt#@-U|I(RcCxCyM3oJQi~-cf_MY<7x|VT>+x$8kbO@{>Vi? zh`$vN7_KXTJrKa|pWK7^-?2YY{x;G4N0bNc2l87fP7v2muFO4~@j=SwFQyI_|Pl4!gjgpSkeoCPMqP$Rw-FTt9H zj1ko6rfzC^gts@bI=vIp2O6eL;-+3N7K~Nc>jBSQ0WSDLPkiD3%trj*X+AkH>GWS- zzz{v@Z$IKIqLB%kHB}d}5J0(l&56Ql;RpjK5cOgJHniN8 zj-4aQz!>|K(_@V(f-{=*7X%O5Y|8lYQf-JeB>a98A1qJ3fh;lTu05^n$z2H zp;7(}%A3fS03Nuf6M`NIf{>AxrV7ba3zqOE3c96(gPxc`T|yuy+(t;E6D;^VfxwQk zjYozqzeE{lR_jd!%&kQehD5oyiUOvtP@@7{hLg!0WUq)cD+(ye}IKjv6)GvX5pd=a*M%P zTMcSk*%X!uwYKwSfbQbt+58kfqx8F(etbu}g^6M$8l)37ou76uWwxugRI11`%~wIU(D zF6WX}bx{wt1J+g!7dU$=Yxd%<;nnmrPt84wTSq@bn;CUSa z*A8_8OFY=tl?(h$GsX6ErDARlb&b}rUUru##r;T^*!xCx-z^t6oBBs}ZhFla?s?IA|E!Pg19v(6I+7F9#)YMg{ zy{L)pN_!zwG;h9JYQUHB3u0*|;($g!E6^>7uw>(+)I>)p{%iqjG+>~7+(2e#hx33f zaMHpESns6s+@Z;KTn{*r>@=7#XmsniFe{g}k(y3rEA2=Z4($v=o=F#jO->vQO_4mR zI}Em(gXZYXg&~R5P8}SBv}g<{0@P%QeI1 z<-^KFS@__$XVNwqgbyXt4*3?HO+(7X;kQ%ABdn~=Wk%~O?KKxX@U3@UQkvyGeRoyV z`Af!k_W7br_uN1Rvu*_A(-&#oRTq3YTzokxpR_^7>b;LzDLXgt&dWoFjQ_DQZ4B5` z8pyaDLDq()^u5||!59FzI7->=#Rj-If=moAnsT@trB3$Z)~DM$;?`$q`YYU_OX<5* z69AVk#RLO>Vbuf!0DS-+Vl-fbBQ=nDwW;2MW1Z|73A8ExX44^Q?q2{(0RIKy59F$I zlupe5#NGcIet!@6g9NX01kUCDMe{OWQ_LEfHh#>~LnMkGu*1d0Z@2xuw8h>B^56h2 zg<I&rj7(bmDh&(p~*~|YyLc;y0^u?jA+qhm_ zrE{`{trHhqi;kehcr^T6*-z0Ibgjce4R2Bf+J8Y%0AD6juR~zbP8ZPN#itiGM<#Isd>sPeFLE1_(0wJ2VGgI(lBiI^gUxox%89gH7(TO33U z6^HMbIA$QuuD6PkjPcQtl!UA=V#f(q*Uw>7Dnw_QS5g!0oVYD_nFBWkgwn2Cnv&we zd|2iiCc6e*O8izny{J6__jZRE`eTc>&;A~WHh$(Hf=)vcY|52Ml8mHSSxb~Ke+;GZH7xH z=>SYZsU;WKS6!F&2fIf8t7Sj6a-B`eV~1hQ?qKw&Ra6JHThz@t2g%6mnBRmK$oFZp zQFS`fYpZQmP8oS~);<4@!KTRjJ3?_4{{q$f0f8y8WPXU$OT{R5)D=P*c zmZZ9ho_6GSw#x)`BFXEG(6%<+xi+qg7?%wr9Wx=}R|Vy)B{-VY5Ho2p=zr0c&wE3f z1O+VDx+`qtLY6tHI^n;Wg6HMYB)C#M(7k_lf~uoWY0vDrC&h)8H}sjCazI9!H28Th z!pJED#9$CQu_!~tOL%3Zd^0GL?X*ErAMbt}1ulK@b>Xsp7*6QSb`tquJ-JVIGkxaN zO7LCS7G*(I(#(v;w%>J!pd~e34zLw6eNytHjSOP-n5WCbi|vWuc#s!2D_NGYv&^gP zF>%1=>ISKvi>1`7d(O(gGYS3q#Aol#YYA(srU6ST;X9q2k5*8t5Z&(bj?_ZVtA$LU`Q}DeTUyuo-(Iw!-(IB*yeTRZ5e(ffq z*Op_DHdS8tF-N~{=*0!j?3b|V;g=CstkcGIhS%7a63)R)dlTKr(*$h2Q)hc=3s z|3(?QP)b!o^3=K0#d#{-dX5V|kF_*rchXdlOyG3Kv)OhK6b`iZ$jMUh#H zq_a^{p^TZ#K-3!72I+{gk*%vt%|fX{8`>t9v7KDSu7;krAdRcv(Jr#fnRW`7TTg8} zYQX-mcGM)Gf36Wl&eHT*n2k)TqXLbUSI!GEJ)3|}ZM31UY_M&`HJ)eN*c8Zr3D1y< zQ_&t30lt#UoCZlMYRn-DIl?K_O2wEsT{VR&oibz=oD;>^3MDFgWO~PV zH%S-v9p@T)ht--HyoDK%ooaF-zF~)q5Is0P%dhPNmN-9dC9K0{@_ivz;(IZGe_GR; z({ow1MEb1EUZb59g;o#xi64?pTR$!l;WBZ#Q5t6z3@SgQ9=cH)3*jkCm)^ax^AMKF zefUwx5>v2ho&m}UOPfjHFgBn*u54Srr-0@a7FaR~Uc&TVg7pPKra|(OI3pcnFR`>6 z+><*?AHfMWVmQC@YSH({WJ0FxXigH?FbM|1r?K&pJ8K<(T8Zg0@g9C2bQKQg)us<(FIZlUE=Z@FEoseVwwVS2YOpQxhgmK!6B zxwMR`!Q~(ZEk}BA`ID7M<>dB--Ol>+cQQ!Jlcr}OB_7(tMVOT5Z&BFV%%jxf3iIdm zYBd8S;SFI7_MyH(wf>|YJ1Up+K6+8xbj!$#Pxvz(@($#E|2dsvpZlFB8yW`9 zasIbv!faO9{oBnRFU2*1f)m0CMdizT`+vz-^{4mt(NEB)dvH{_>mMufc|iU^IEFik z72rex*+YS%#@uzz8LqrcPX#8bN~88Uy$EN)wfj>mu&*Kz_Vlqjgkalu7+xr|?x1t1 z@wKzC-BVYwd%{^9sOyt-fjf}jeI9U?mgD0GNgjDkc%oc=ayaqGL)^F#6-*UF>3EXQ zrm9U=?@poULiC-{SE6uj%k9X1ZXCFgUNntN3yC1;PO-TAB2Dy08-4=c6Q{p)-TdvP zUHdrC+ala^%7AI~NgcGe#-X>jQ5+LQWe}5em7jtV1w<5y=t<@f`h)0755;6XCZMQ)c1Uip>xA@ zOH@tZFwrxX@;;$=G9;?tm|>*_lFrx*tA9pTS0f1`YVLK^W`A`*2h8-h0d(hSqf0$o zI;iyNcJHUb)5$TQ&JrjpnNGapubsn|qQl0y$#d-LC5sKsH6}kSi|(Of@prVoJNS*` zYV4KGHFVA@C}Xb9QE1h2082F1U9vReYsP*lamj*H8fI?89eXG13*pn!ir^i)CfKKy z5v<8%Fk~_##*#Hmo8=B+Nb{k58p%iXa(0<@@i||bETsL8a^|r-PphEN=VKuf8lsXT zY5Bp^e0ueM$B3*@!3M)-(utX2ydM;J@;!p|@q?;!w^;+X+_DBo+XU9jV-7mVF{M}U zQ+3+2cfR2heQgeUeM@@$VyrMz!;C+BvN9q6#@J(Xy^e8wY-*(z+adu=4T?}+^JNuQ zasg%>MfoRgD|FH#8am38@gNm#5|#!W4fpwz&aVk$G2*b4??pHTPyDrUj2K@Ld82-W zXEB9DP%587Ri_4>zC0YS$99IC+d-Mo%o_Q`{3d-BE2;_@@V>(?V`j$771g#x0+j`c zOYZcYoYNp0el-Zs`k4SmU49u{s;n(0{nUHD^0y$1;c@!(8s27$?HgTzejjZftPy3% zuT9D`*>iljsl|4XN=3WiuyeL4&``lhp zrsM8zvgxa{?}1gu$MXOK*hd2iK#KxsZ{0vC_kS6HBM?3sfZb8j-!Y;evp0RzF6*%A zx4#%@p17@1hU}~IgMgHH-f2($0eFZuOQr8G2&CidRa=48c!4}#ZRf`0`jyizVCv12 zG)!J`XRRabRr{{IfL5z~o4;ZbENvXwkqC$f?ST*b}(yrH`WJQDbh{1V@& zDphGb&01wynk&{~BmAu(gt!Iydgk>B$X&y(aJVB+-#&j*y6o}UJz)hwdIS#ys-ru&pGkMQ zwlEgYTvT6UWye>gi5^smZY%1_{Gi$ED~R>hbSkv2E5tETg}#RaThi8jv{KD4SctPv zBuU~EvJ0{)ck~)rSFpY0cM5HXo)htX8JrQ7^H{zs8ojp5lKoEMXA?bke8_l_OnVWf zBYuLMXe!G~BI-!3@J!$1FhBS-6AO{ULhrT;ZWMDWl%aq74szsDR#IrR4DEjcqb{W< z>?@@Q3H1byrA-(b3c|-OO&QuB9%#7nXCBF?7~6Pn#9L(-M4Av{&xusZ`C5^pajEjB zm%jGgE}1>>SRdcTA1#;X`om@+vBm3H9%NXf5m-jdan!29@)6da4>gbBWeIkGc|V?; z%DSB=$xRP1ueK?KxUI&s!H5hu+zDECDy-?aV=4!jux>TTCRc4Y$R@pC_&+?o1z23a z(>A=gySo)D?owQfLveR^x8m*v7FnRU6?cci;w-Q@h2k!S;yYGR?t2ENRg1&yngcVG#-q#lq4Pt|&qN^Dh1ubw#BFd{Rq@odb z$rV22bB6`;OmqCK2?$OnLP4&eq;BcZ`ng~*_%bpD*N>Egrz@^fKcEPHI2{tlt@W)W zyS%+KcY$_4cie6|w+0hJHtl1BVUGP%C~DTGD5@OIv}X7hV*`giReCk|(zFe+eDB8S znW6~hwLCq@4`xj!Y(<^+KJ!f|$TY%lx1MO)znXOnv9(7T#3TGjC+tFMMO%$KIE6sK z5gw(@XBr+M;*&S4idqjwrIHKaai*k%O*3T3djiN#vrj#UT;W6$NQkT?Ql5J85a-9C z@4w7`HNZ<@xFRGDZSVE4&OpTSeRpFpV_b0b~DGBQ&kK87+7}bovyH%46pFl%uHl0icxLtACXuuw;be=1E02gF2{UNh(teA{!ixev2E#-88s{Z;onV zi)w$DCv!=-L`hx;))b9cvLM%LV>c4Q-HWDpTW2Ys*PU}6(OHK0W};YMoc?ZGd6O5S z)Uj>S@|~IVRRw!LA~&PKM%N|Ia8y=7EwFS)Q8)!Y_svCqh9es<@h;NLy1u@n64)7R zoZr@_&vU_+q0iR_1ww%hMUG_}Z52}$VwE3@#c=1oOV+9-+SoSUu&s{lRQ$bAtOqP+l*mxy~WHN}1GRPj$bg7zM!OFOnnzQ&fgGN~6 z1|p*cIL-_?lHMOo`B=M6POi1vNXwIM`t*}xTtB7-fX&`#>*(tl;wN4wp8wuH`}p%- zLg*09Ecl*`ET-r z@az#W5yNE+kt^e^R7Mua9wzc5^Y zRjU>-4+1AeMA;#8j_BbZd^}oBhEmGe8)o_%M;nN!Do}h6G*~o*HP|&U3j+$t#ID(* z+k`tK{n|Dp;!HEWA5{N*g?PK&Z63$u>o*5yWv$MEX1G^_M8D7>Dsb@|g=1mkpZ7o* zbWJZYAxqK~XQB*4yg75<>}Hdy%xl3qzgE1yViVhl+sTo@m#_ipX-(i5*T~8R`|IUy z!$UV{_tU2}hA}A{FuC8F@QcJ{S`jjsRpVvQObb*Bt^EOd zlmvRX!S~R=FzTBJM)nNV)*T$|(bmT)=(R%9cl?iY3e5uR6Z~&(A>&yv)&<+=H-3OR zDpf9@ASTYsgZjSRPJ`3tLR3^LmB6QMeS_-d0Yk?6Pf~($e4qjpaj#o?A+t)0w+{K= z$l@2c5Dc~s79*wMfMpiMw3gclrW(z)l&925_tw?m^`QkqWdZ0wG$Fx;swQ?o6-mRL zBvB4!au@aVmnW?s4K1rc1PY5`ig*k*ka^H+3 z0tJ|hpaQTkVOxQ9w_RZsaZd))r2M+8B9$a(p_4`azg=kY1k&`>4%$w*@5_oumX7ksX6$l^~yCu;+eSO4li@tUB$rZcC z$ejHrJgch)vPGt4DN+@=e^t>T;}cFPPEqv=+RX?LQP} zR$Xp}=+uhqksdb(!05isyb2`KV%DS0+L@pOK4wf?NkxiO8j5Way!y)zP34!u$GuF2 zY3IFvm_ne3O_1+`Y`($yTV~r^LzKMhf~Hy9#plEPqTy%BTBpw_e zvDmXH*HIZ$8|PtD#u-EcyEf?&%8BmG;54My0Xms=6tr}=C>``Be)a6zW*Z&xO|?$K z@>DCd725Li49W!Mg`ilp44p^pPJe%zQuheKz^cL+=2$T%kvJrRaY?h!#cRI_8U3JG zb<4+v*ggCJ?V2U1y>HkDiYO^2&t~S9xE)0RqUbSqx$jjAtQo~n>NIdd5%J}*+0sJD zsSy`qQ)a+MXBc>s`0rfXeN2sKz>dW`3M`OL;Oh0N-D?S7zuItmL@RHeo70VWkXMa9 zd@!Y21xg%OsPcUiG&wX# zvVOnYK*Z&&w%cW8#9;rn@WeI+fK$#`c=KD_ayh2OA=a6W_)Iy+piRJ$;l~4$Ap)vv z9k(OQ!llhj4d|3aX2xZ@e65rQ8JRvY*+n`o6@}5lM96UY!*#Dj)Je0w{R?E=zKv zq(@5Zib&xWo}YPr_N)Ha-*ZttT~zL5TtSp|fm!u4z0Wj51(on=uD!abC!w#4y{y#yaxlhK)d31xQ@~5q#ee{HxnL6@-MeS%)7U%5++rfx?058#-GAeR*-e`%kaBZ6w8mMp7lxnFRx~Ntd(RxsS-o<(SCpTZO#hn z8D=%ym2-yIRA_?NiuIG&#VWY=W23x=_!oX-bLdaTA!_c+2i)&sd;OLlLtw!y!4fKw z!_SVHWPjkcci9^F8yONaUWjgTz|+_jS&u6=eU|*ihAPR%%U=D*1e@YdjUVV$rWAF>Nrmcq zJ*j!!S;C$=%h^7Q1%k@5cceCLw^_~QREEK1p`60$7++km*^vGMPy^zBDczz5C|#jl zp?*i2TV}voojDJ;=+zg9-8=lT(K*k(pwHaEgf;jV5U!HMXDbh`c!y|R@COrDB6mB) z(HVVcv89V6It@FriVroJZuSq}obI1#>&qjXk7cnl5P7Frswf?QP^q-Y&7BqOc^utj zvbx7LaU@FD=QnOuq=?pT%SZE;InBpCe?}CIRbG3d5LhyDBTjMXD(*vv_*lRAs@5m1 zmkz{r7m^J=-|mJ5#Oq_1l|YkO48A$ulR@gl+xY1~Oo2_whl|vUK)A?uebFg&K?#xp zk!sea9jI`jF3qF1&_h+B22-IcqFOl;is|;udF=Z}v`^D&4RdW%M?Gm_`{)2Th4^e9 z#FmfyMt^X-;^zJ24?kQDm@wyzxgL8@90SK+Fsq*Sc#4ujnZqQA&?T51JfqHEH5dvN zsc9xo#V$qcMQYh(BtC5sA)BaE{4udwa3}$o%$}Apx2WXMFrBq<>WxSy{o!0x+%2H^ z1=|cqe;s~4xGMT_jSAcSO#21elU&=8T-$FQv!-Nt(u9pUFKNHbe|%V~7 zHt93Y;WVbKBHgWpN6TKm*dpP(Q`a2UJ~CD9djk!XCpC;LeR}#-ovagJ%{S4E+C_2wyB3YK1X^fHN>oyt-cJ2Sn>r1M>{_27I zWT_b+9yunEX-aSt0Z0|0$ci;{LB@0NuNSz)+=)SNA)_c)R1}t0bBJSVKSqOa;)8mg zoXvU|lrtTIsE3G0+74{GVC`OkJX|EYG+htR3MOLyn9xg{!txm&BU#;g;wj_9vSZ-L1 zl3UE@$~*mg%4VqcmY*Pa3dOqQfd(8#OScH^(J+zb=)SyrLcn1}bM0Cr0)l5< zeWts3U&?3hRq-OJxFY0mFg;iwKrh6t9+ck~z8_`dCu^~0ZaY&Ywg05jhgCWNB~Qt1 zfcrV6Bd1!^BW2R5`jyfb#FOv+ya{edYcMLq(f9f=(UMns@nVuzCnbSJ{sbic3o!Jk zYFxQzi+<)OaprKo*%`Djjt$T;M{OIL+-}sTS+QTs^3xgMw`U5HPN-ATPU5Fzpv5Iw zcibIp+VVO+f3A{RwRLg$-M+oGJ$lY5a>^RZZrt-$Z&P`wy#F@lknbgc)Ry=|X$=y& zk=+hdL`l|Au2gGEizSPN9KT0dM46GY0zdUbf|8Q@hXjR9NNO-Lv9+O8DssJuQrpCdhXR17Q#$4vTJuY!$wbFlyMaM(7@ zHGp|bFDXAxvT@ZYFN5r@G~5ssP&@Rtjt3SGX1h&N0-;*^B&~XQw7oR)w|Cwd{tf0$ zey(BQi*;aRTBFG!;St%8y>s{e@FSiLY&UAmRoY0GKD`-#WKUYnVwhYNQa%?+FS~Ve zMPXr-n6QB2ket!K7cxQl=p08y!_+(O`w6xm0b26Q@_hJgbN)`ftnEsBT=toDuPQ$6 zm-di)sV^*S)Ox^4Se6*!nSJDExpQVMF`u(w_13oYcZ$zwdnL0L)abqh^%r9NXTAS* zue%VvTm-AvwlyZf+Gr2~DA&Cr+K4{d!w6!));}5?GHuxp^wF--6ZoSgUnOcHp^thQzT!kqhAC@}2dpYo9TQ=v?t z(gCbZP#_D;|E}1Gc~8nh2GVp2kT)R$lC6`$ip7tv&WTYT-*+nMwW1BcP&MYMmD+vf4K=Clfuv=%!B zkKNa|S~U|hbfE~db8w*#mGBCN@ZN=xk17$0;wFv+fE6|U3wVB0|EcRN3eIVB;eGgN zm5|N3eDO{o>Vd2T#{uA|DYWGEt&ZiuMJsL&oFG^B2}YEG-GKk{zgKf&_$|nbhv$TV zgBjj#jO<7LXAj6+uFI>;_;IROx;>uf_28r>r4~YYM{$uy8x~)gC!zPR3x#p5bR;F0^piOX9KYF$Tx+qzUh(c$BS0E5eQxR`iqH|{M^Uk~J zOKHod(P`G&u3XTC1J}2@ljEYk{cfK1nE>+kiKySR!66SBae^kQZFz;tC!SGdsorx+ zhVN4_ziq!vjm5WxP7n&0uNG|Z^4t+<)X$Kf{1%8E-Pdy6;25p0eUG`Z(p#!!5~y}2 zdPk-!@yKRXp}bya;_Ro0zV+3UzvCPo_Od=Ou{fr<+IF~an9?|4H!J($z0AvkPZ=|n zh1dr13-)p_fO@`OZe^99aqUoxN3AFePj(RfZTqYG6ToHbhHO@ieD4 zBtGR*VW5)Q9F7_?OoHl9k2vH1u#ACl!rsau(MiE|U^yL&bBb)Y;4EbY7AlP%;yr}5 zvhvH`nDrMAvWXM=n0>}a_zTc*sCydGIs6G^9rIB3{!-jZsGni;6s^rMm_{ogJB5xU ze|zR3J*;CdsHh-GuC*X{)x+!j0!2O*{-=P@#FGq9v>LcRpp*I9+7#ug{1a-G;Pk+(6VhC)roOy$^$k zYq+c!6lez)u1!9}6jNak5u-nW2+u8?*c~`z_4s{3c9}l_SI)^l@~Nc>@mf# zGP-jOZ=tQGBwbKmLfKng*ATFyi&>;j6tc1jhZ$t`gB?U&i)~n8VgW7kJ;Z|yOXfZq zwDK`D7n7+7r~Fsg*+-gt37n@i7`lgG|1Y5W85UqC<_G(}Mu7G!*Havfg^IVU5lZu` z2#2z3#bO0Wm>_>Nw_5gx0MTKb<20Z!O7?>9?kly8=W$)VGo{}+ z9#v+cf%FvWAG}mDw*pF%I7g&9g6IXXm=85?OD=#u+(|BAeprv$m|kCyXw@Lv=*$a* zIDltGSI~(&-*{jI;3!O<`AD4Q-b;v3U|35?EWe6mqA#^%&__;e>KhZ&LryaA9^D7W zLlIq)j}_N3Pqcr{sPf}CnTonDQjFYzX@_ye$;RThG9+3yaQJpclU;H8PYa*ymHQN& zLkz|dLT?8)eD-OgP-dUZ6ruqj@!3(+bo4#s!x@SJd}07{xDDd_&D{I!vWayoZEvRuo#(Uwn zJh4i5u(`zyRy0GLB?nQqniHR&nX9TBof4a0&3XfR@sh<5OM9=r*qybkq)7C%O&QC^ zbHE-*h}?paRDhYBv?4<@bEDbw@Tu*6uKku0Er&}3O`{f3;$J{ICenO%c=v8-+z(Y; zH`C@jx3o4%$=FZ_TY<4N4kpJ2_Vzq(;En=a0Y=&-waptZkkyW{Y|*K3@JhG~6W*6CZz`u9)D%fpOnVA!gzx1ny_R>yr7 zT0w~ErNmI4plkM?0Og@;RjK>jOF|Q)OJS?*g+#XL$YhK{W%XWHS>O=Mr>P3W4uFC^ zoRS_-6<;;>+u3j>_l`UoUnfH8A_{=c#3OI^E;lc0#(7yL3&+bNi2Y~9YlX=;c2O>+sttg+wph7w zL}mJ|h*t!&a7uQDa8x<@p?2#G(0UZF!BK@V1)q3exI5<^xLdsG1 zKAcTEzNq`N4e!j$v=X&V;6U?~S^U?dIIFmm7))(v^UJ)E-j?_@Y2MkE2uyhGz(1BH zY~;HhA8Lq+(%O25#o!@hJ=%ZXQ2Kn#o9-Z>$U*Ox3uHH#MsJAVYW&hBl2=a8Vr_2l zcCUL-CI80L3~6M7u7;K$T5K9v&`ijPXgjf^Qrg~E!WZ1|)peX{f=CbC1l(6|+ z5oR$^V*#GB97u`aVauJ$m9{Zbrp}Av_rv-szE72-N_c$fFtAT(H*h|YKO$*iYV3_W z8Eg=)Fu}H`1=21M*rT2yaO@@#1}huk(#qCNWa0B_79e}+mO7a;5fZiGdOz^o)JJ;( zeCgg1(35ONL>4fcydmz!tl&G4)hv6d=%rOQ8zJ*L9Np)#aKkhlbp!K%T!(m8U)YvB ziUc7@kVc!M=-l=E88}R~MjU72_N9o6TOc7PUkfkfkqZSWN4a&AP>}$-e zjDIEqDKC*GQ&XU_?bfCNA&z^ZF5X29rzo(NaVS&Z(8=@5%&$5f803plE)u5CzV(*@0@3Tp*NT}&{c1dc`bB+C&-?84!tvSJX-`RlOdXUSfo9e5wC#8I{6s=NT=@deGegM7j> zXjv*b7&&j2F7YrZIoEFb^M)*3j*+J(Wd(MCRK7*bqcKeOnH^eiPu&t#WT9Q0H|)!R zIi~9CCp3pAY;7IPZ}vlj);J-3Fx>1}QPbWf8%YjdO~*IG5k1yqKB5BT*H0fV7826o znT&YqZ<0*sK4bv2*5kl0`;(9#UcV(bCZNZUDef~{&Xp|_s5;BfX16j0 zwIa}Wl}2Tm@F%f)l+WUV6r)SBtoapOmO@B<$!jXQKZ&(vxr&Ycm|CbLFT%~kp;#e; z(8K;~-X8T8ZAu9{zbzA3$b=|*m;Ur^7s zFxJJm!<)mAHSToenHA$A5jnBvvPAZWdD}{l+{L~NO67MGOVpu`raH>LOl=vFpGu+P z0m9oRd*96hCn;?H@Cd!4U%mu0BaPC$Hjq=-W7)2@n{EMIfOtRXpZm$iFv9*8?)*t$@8%B2^pvp!|i5rd+zVA?s@3Xckq(qm> zs{3Dl$vEY`Z^`ZhnUpfL8eCO>2XkATBL5nW?$V)Kg?AVNHz6aUePrZKZ60YQK-#f` zRJSJgehdCmC#KD$rz={=n4(2-)K*ObvUw}?0U}x&-cE&FGb4$7;+rriFDmV$WKd#j znQd3+QBmt~TnsYz?LU4R%VP3K6MUz9N!H{LLM7OqTvBP8%K1~g`P*hF$EL~Im0N=^ zU+rg;^J8Pt?&W=?rP&(a03m%?dA8wSA&G#ZHcm3hUSG89*0ah4fDn0s&93o_7i^sr znB1&?cY_`b9Mze}*a{{It$|JC){$9z_Tsg3_%W$*tQ{wKzR zsRDfI%$j^HS0c<$KD&Lp4Q4d4)K-^=s|0lHRR$+N3dCHHiUxMJ8HydGDScAm%?Zeq zml{OYwB`j>m|87hReD6IS>aluzL^0v00yJEDG_Gz_rAxvaIcWS3maIq=TO|-Yb0EO z6wm;CiA=eFa$d6MtAS=mqaG{2y26I~#4Cpv{!B)1e zJ$b=;@Bq9CRz3(;4&?n$+sX#|r-jvLA3P*XJdCSt4!cV?f5_<`!=jaI#qwiAA5A4w zvp#>F164Tu1;j^Z_hBrGgmjx6KQS;r)$CL@!YkTwtw zqg%{IYr2J4#te-eRd(ekcL2k@|qa9am#w3#dQ|R zgR(+5mhaYpGq@Zi-U%7fPqR7;wpf zjBWc^-g|d_O-+%QNneFtC#RK~<>PQt^70@jDW_=H{!H0&j4UiE2NZbzeaao#%8rZY zzQfUBb@rLP$Kel&aCmsr3iO)BgP1rg(>d4*yr)my_wPjB%i_MMKCRU+zQrs-{v_6@ z%4YRgXIYZM$2+=m6xt1SUD>JrhQFJ`PQ0;n>VW_Uqp^{zOWzeCJVV+pnK)}gI85L53SOd#+84!YSDET0uf0OcXXs^&uo~@bQzIycNflg7T%43 ze#E*D_k5{3w0A6{BH>%F__%^?{OOFvNBItjNG8`MM1T5;&qQ+<8Pg-0cE}~sHpUj! zB|%`=&^z1Ye;b&=nZ!yWT%@Ny5q68Q|C&U^W{)=dy^IU`c`ww6JuWq^uY@XBFz~ zTcq&t5t^`Xvkrbuq`suzo^@h*I?@?{oMi3zXG<+GDc~hf*cC67MJmGPT!J1RW@$F{UE{ zN45%6G^3?%54vkn%4f-sXg9|rbcfK-aYYh#AE$qVZuySvr=jUZd=gABk?fZ5zucfow3iG93=ZK4VWi0m=EvR`M8L$P4{_KV{h1Y&a6d}A6IA|VO8$H|4f0gn63 zQL%Arg`B$2Fd09#958FzKy+vb7kiX(Yd4 zF^N&I9LuZQ9%^pw3i&{+zCM+bE!s_gJKWncVvfUj6-0jA&Ai87X0ySDyM;`%-T9oG zu5H%Zms;inul^h}o;u$k7{lUy4{hI|l%Mn0R*_35&Jkzr`(n6mwW@}nTs)x;ONb0f9)l4tZ*i6_y9qvxfFEu* zOayBw4`pe(G`>D}lI?pA4V!q~$)*NJu}pz>()}Opa`i@kl3KX*G#mkP zown4=y~g_-UMqxyqp>oIgn~+=(peX{zEzvNy^A{Wu+6HYQ2(_c=kE{{EYDTqkY@_A zCkmb~*~1;xw3M{Khg=GUfu&!T3$_#w$zKeSEAV?#9%fPWW%P(I0>e`1q@1aye`xC% z$$?o)fE2XTB@w8&J~?S}Jsp$nG0`tEY?r8&Q5#7ny{&e3rchW= zP!WQSnY642$s6P}>lb@TG6g^ot^@K$mK-Kutvqy9zmeN<_DC4Mw$lS5D~*lXbQ?b`A6%9f?_G8|iArKCqu*l!nBe%kx@`EDt0`$IOIm1NN( z(iI=Zw+-$2(0`}iRx5im7EId1CKS==ApC;KC1%afGFra>)p@vl4K!9(AyL1QR%V-u zd{E`f8JP9R{-j4}S$NKlX)a@Dblw&4#n8+KE10)S;b-+i$}i4rx0{fx!(Q(7us7z_ zxwN&d&jqwzD=7p5+5EUf9jUo&GPFSX76cR)8Qw1*R>;`eo5*ClE>yfl@fHgqbV~0V zOqStLkUVzz9vyy6+8tZc<-9zNGs|F5C;(!4C%1?Wu25!#SsjucvQj@JJHu8S z_Ttd*-1;leKjtBpJPrqZe!1MLXx)h8BZW)WLOIri67e8;A-ugk2 zxJjKrC!*6|j&$?E3@$rJ<$W90k$pii)*g-*!^sApH6%~lGIrUDxP3;7P2O?pd$b35 z&YHdo4U<@899259pg30$jGMQk@~yme-LU+FdAkaj)K@|8&_G1i=*5oDo#fK>_ec@z zO!zyjxRO5|kA`5!{<_fKzkm+=%~xZQC-xVFguXwyf)kg{^X%Sa76ca#COfk13~nC% zg92yf1Yhc=26cSM)3`VMk>Kv8SVUfq@^6L)bXpZcFQXe6&qY+5_!6?HgjhI{zyf6H zgcQ}nsA9Rom~>ueX4q)n{D*N=k&yWNeYiaLWmKY)l9(>L-(+3z{Nf~=9dA)jeh8to z#Qi$mUmUCgF}x;-C5R~pW&{vkv+ijRRz*l*$aeQ(tx2pEtdiWeRy}9iHwV0IP&J1Bt_YRa`PgqN zeudizuPs*}j!Ek|_$KY1XKf0zG=b|h?a!J@kvGv|e@b(Nn&ZyTw|b(k_RR*Zm*Q}R z$ip%F#wngcQd;k?N$Uv3?(2R`_1GfDi;$0JvE^3nks*r7o3~6!*$*c30(}pWOPmR{ zdgOF`i9KcJeFsU!Xqzm+2vc?oWE@dmcOKoooF;vHkSTU)%B~oLH?heN$y|2bssl7x zk?2wLFW)T^oRI~|l<~{fuSwy{t&+^&8-4rMD;6w40myA3?LojC?U>0Y4>aU|UlqtLJ9Ssh?ACZ?M*iDS5} zuC0e=KVA|t#HLuIu!Jg4Fz+ppWtOM3g7-^?^nUanxMWy6l7-vJ)6j)iC5m%rC@N;D z8f@a3;iJHAm8oNu;XK067jrK`nO%HsHlDX8bam@5g!OZ?Lgt-+2l(*9wq-Z_4-Ap@ zm7{iDoH*Ffvf-$MOlv4{Jaw76=1OHMlwT_n@UZJ(j~$DQt@-Jc6YSC!18m=siOD$q ztm~1N6PU`hE0hDogWNha@=)#a>6eje_EqjIDi~R-MOIZGi_zN&UvnjT$iSpR5Ty`5c zlsJ6;!C2_F(IDoxc(FOXIQjjvg=QSSO@IhIBs!SbXyyjALW8rl)KhO7yMsZU{Pf<|yAd$Skev7_Y>mu6?|CR*YaAgWizK zO0>Xtuz^dAS~hBFkOTcdh4;%!^s+Nn4K6iz^bV;OD7Ts|=a_9efnl8O9>}Z8&rCS< z{6?|o1~3ynC{R<#ch(#9>gtbP|F|j02Jbn1%5wP6og@J}3;4ld*j(w6_4hZBWi|$y zT&Yf}RtkQcg}ax631rT>87y%}x`ve^w6^0GFx?D+1Ge^i?%wS2rnU*pAF$yh2 zGh}~YD%47``=~7)TL}-fA}b!!a4<6Dh?haoV8@}TNVI?>F+x;|M z3k*bx2PRPR^;((+h-4-p18u#!?`^eNB0=TW*wa*$a%3fN(V4Lb$=DdtR=~)? zb#YH&o{+r)i`ytI3FC-SP?Hg}Z1`7~vTYCNM2g?w3Z6RwiHmk)CZT@F+|q2wRLJ&W zR8Dbs_LlMYjg?V3ruR#1IKP5i9d!}s&VEUW(NAb^#QoSA?fvp;5>!skMCI8YuG*Zn zG=Gfb8=d`SsG!64(=w8NcrO}r{^!A9UFqqTHOy6hMKP}tpU;NNU1Jny3O=6Yp^;Im z!o9=E+>Y5RvcDlJ6yX$gJ$iiU<7hwla2VIo8w|D(JOv*Orh~%I&*Pm-W;s87`S!FTC;^kiEL`!{i$j z^TwZ@hr6sJ8;g3_2JQSKUn{_P->FclLJBd#fFD;dyxY)!;yW0fkNxX!%PciW-H~4_ z_#eHykOBAAaF_2N)tju40LJ`IhB3ce2knKL3mIU|F|Ww>V6Y-Sn1cOEX@?ia@kWA` zRj)z2e53Y>5T6U!(*mZi$@H(FXfVE@OhEEg^&Ur6BzLt*?)8_wMQ~LFl&FwS%n7T? zR?H{>c=cr$Ru`^;zSf@Nj-K|r4s$p_hi9Q;SL@qyN{zs%io&D%N=3bjDo;!(xl~6X zc=}t;ZS|?q@$fot5K(}0dvK@6cq_Mx6T=sj^RMsMSx_?d)mC;LNOAuL*Jd$^el^mcF=~x0>2kZFc z(ozW{e(8t|>->l;Ks(=%Z{F^Kwdz%(vrCzshD+1cnBji#VqK0=~!kp?NCl ztGYi6MV<{@jq!WF8UNT@=|ifW@tN|ZY+^Rir?n*OY8aTUp~kh7bjGYEN(nn1=hP#8 za!=>fGkmnm(-=fq=VoJlv`ao?p4iKZlFh5f!g_rsJh4}aJhf+;jYDKAMV>LC8 z-A=4h$2q4sGc0!H8XuV_Mqmw6$Kq`?a&pe}64y0f>kua)xY}ecYO+gH%?X9YtVG}# zr4a7e9sUeYO1K&gL<^+sZIU)%H-e}c|G`C5?`ZuK{(Fhg_JUVan$Fy0SFen$J{ZsZ z?N#`!c??iu{w@#|nLYr!6G1!Yc!Pqn_%`$P7D^HwCfX*$NGhw!Kf!{^$(j-xU-8BRspF=cu z5vq)FVaB9Qv~-*b3jv&_<|e5Vv}!o?H+*MH_}M2y9X(+l2m(+5#JJrD2(!UL)Y)CB zOwB`aDG`wj(dP!3^ebE6B>V(Bas67755?o&_Acij#4Byhap%~}WGx>EOkRqN^s@Xu zR+z@l_R198xvWQYis`;0n2f)n(4+7Z4uOle)Pg@Uaex<8Kz*@*rJrE}c-w_p>SUGx zAh8Ka90iI00t>9A)zN>WTNL+7Ey2%kLb|uoP3H%GXl!e9nWuh;14MSH>k(e9w49|w ze~^$#M3Zc!>>1|e|_zf-)<8+kQYflXA(ksy9J+u(OGB_jN88S zC|<(|#vE;}H%o6MGpYpHBfZn&P;t`In6v7T5V>A4jKGKM-2Q)IYm zkDlGW*-2*dQMUxglyF)gode;_)jd8!_Th<{6K6@84K2t4f<-C{mmPdtwrxLI#yZ$M zo;tk)HlSF3lr&1&(7x5(AXbt{=@ZN&z9dhRDs_JQSTwK5qyXtH`I&dFD#!j>KN$qD zC&qWoe4uMg(vQ)#hV#;#(%8zJ${{sm9P2bOT|UzQ8*AR@*6<^r2+vQ;1JRfmrxO-F zU!7=nq*Q-iO5d))z#g_nlsS&C^gdh;Hd!3U?&Vlg-NtQZRPi`lP5PvyY8I}cuvrI} zd*OX@n(OHFBT20B>U`IRzfVvV#kTF&{=|)AzICWW{ibE*A-0w}YDa%{C!(=|PF6sb zG>FgL!+x7=Kf43RhCGdD7731J;zUP3`B%cqY>O{J-EdxXqJW2b6vEz5gimeU*rb$^ zi_LJ0?Fe(qf~+A(ka01-JMxRP(iP!zfpN7luMTGquzlO*ImVV_tT!+6R=~>orPrF@ zdBBBTZcb}a`Bx2K|I3fVSmS*!m&3OG%CQfRmE@L%E9yjvE{4rTB@F!xmlrD{`k3U48$nQl}kMhZO3y;M|Ei8>So|)0Jx}gj2$e#S|#iJZSvMwW=`Sulp zUf=4EsZRd_a5VG_7rDIL+2*z&EiO%K{mEB6(9jFdl-&UP9_So}s2i7Oz@fml{n@Vz z^Xji^!MEPQ*fUR<$$a1a3i#HbKLH?YMVa(13N`J@J!hP{IzB}Dc zvjlOt*x-$ayZg1Dt#|&-HW0g+!%GvsQSd`!Skd#lZ}kJhW;Y#tYmL8{_FiP0jZ^%) zpEBq4&c5#0wii|OSG{H%*!O>x4$ih^vwAQs^BuQVb+(uMP4@FVuy8bpdH(cN(OPZ! zlFeC+4d%(tn5Y@(cR%7!MnU#-rrr~}up#8%)qmah)zo9XzV2~7h{5XdpqfH`uSv+i z)7xAvczj7uc4=&OYmxFeYlv z9t%!{&goZr_HnFrHw>K^sKQ*jU8jl$xjZ_y7W9u=7QMi@8h+M=H=UqM-u17Fmy}-$)eb<6~>r4i#wc^(OeLiCqZnxX^C*E7VhL?>*pr5T({#d zZiiW1d%uHr1I%C!HdcD4wzpRDw}ex%vAfJLV!E1=86mOQLa6UB`D6KAn!wkBAn#~K zQZiu^&;2%aVb5f4yIxG5UDRQ0zT__9e9YY*2CPWl%0303w9sw7hmKza? zKnj_VNE3zF;y-VN@NjVOfNqcaB>E``4};fwam%D(&{?E!*1} z(ity~B*u(MHzb>`tmLdDj>M^vCfj~j6UTIRJ*B#(4bmjt9P&FL&oeNCyFECuRlT~M z(}Zna31?Y+xyfFTYqty=bW%Pm>$TOLoP(~!Ly^<;u5|Y=fVc$#-JmEhD8cKs`Lgj4 zS+bn;71icEf85bseJCR|y*~_f57W*?fh=R`x%tf?nFlBT6gEPE56U64Zt%Eh(?los z)E-qt{G=E2L6sU833c-YMy$a*b^3m0MIre5lUe>Y8fmsy{EMM3C@I+_R~<0^c6uWrB1pFG%{#|sl?8`MFKd<2;ksNcF5(74 zODlU31=%eV6wy5bxOAZEgwZ=Dr2w?R43mzebPiP+C$wjxXNGkcK~~{CnP++eyDCPY zO&bQWgUuTb6#~&cUZF-y?u5EUbR~~uH2yp_e20nnf4{0O@0u?BZkheJpQF?&;QcD`}H;K_`fu-NJXeME>HQ<$MbrA92SWHc8n`h!Q_9X ze+T8q|CPu8PYYB3PeZ7sNb|3VOsK|o=>sQ@w?azNvHz)r*_Nl0=;h66xq(LSiYn7c z-z2+wix0oYQBeU8leg8YdNU+$x0d6`*^>R;x~DiK$bYiqVww;7lV%C#T=Hz4!|D8y zWx%xkH?zsU`uvZ=h6Y9Yi97k)Wns)z#ynH_p!i0ybv4+xxY{b@w|ckGi%|o#n&#eU zvH*U0@N0ZRgn>`7x){q9h06l-ooSvqM6mrv96>6g)6s+GiNj=;hi}nr!JpQ!{<~t* zDZgs|*KpVE8B@=8a)&L(;9qW(YnmO`Qu%mZ62lqe+5Si>2mp7G^7w?ug?Ugq@`o%!x!4((p z6+b{q0KaVRxQ;j$TGg;j6plKjfFm1s5>a-3Dy%zN8SHGEIs}~!d2W_-Ar&aEz&uDp zS-AkxgnlF9oM9O>tX>nAN@9f=#mk-(NUs`d=1wA+qS-`$I6Nxw>Z4eo@K5*@$Fvz& zTAB+}g>%9ct6TpC9Aj{aFs1k;ou25B4&H!`Yta}JH(+h7zR(!>Z7}t)l>4;k8eGvbDygI!rB?9e%14Q7Qi*F^ ziB&KJM`Egx=scqe94b&^(ft|0B}!~ld&0jf>*RniDltCx#JxqnOkS7HN*yw#AWZmr zJ(X@6i^%qea^&*_X7ouVuSo>p4K@L9a+$CFIfqYQPfs6U9=(Uizhb?T&tXQjsG>_r zbEBf7A|nMD*4Nh`q7NoAl;JTR-W<6gdsLo+5Q?AXEC3`4l@N>bmlU9>ww{w0&gd)c&ZR zU`6}Im?H$ibJDT8y4csaxU{rdLqnU8!3?@B8DqV0eu93c5RLHhfaw@65`EovY=`Gd zx*RKx9Jo5p;Mr#qb@i1L7IRk4HOf<&^7mPZd12s@@MOpo=PW9w6o?RsFIGH~RabAj$q0yv?T%FndQzu2ExCX`ux%uQ(5&V(!ICo$^i}?X zVZ48NTX3avnzYNR5T`ZZ`YJx*%FrTz4!qf%(%nM*pm* zMO8!o= zADe`y-+ma<(7`vdkv_@jPt5a`Wl^LdDK(B$q|d?_Eel;gln|=GTH~BxJ|h744=`eV zF{?e|3kc>@6@*i1_8=M6{xq|5K~!nATx-E)w)F$35MO5u?bY_fLjd!<(#R1Mlf*fR z7B2)Qdw@V7d8>xQAR=cZG@|A3L{QEe&dI@aW`Gzh9-kw)%CNhNIZ|S3m*&b?k4bQ! zmD+D}jCcFz(bIN>6vSvdD4Cb_x6GiT$>iiOa~kk%lo0uc2dbc9@z7dm@<|Q&um}e=A;^JoIdzk3IZJ^O9+??r@v6VS%80@KHrIiZuYSokyAvFN zNmezD@hzO-ovg-o}5Lidf*GKW1YfCwlr3kxhg}T^z=j!P<3vQ(`O}W>rY`>`z}L!FAXwC#d9JepXUwV zbs0@6$Lo!TT~{X0@Ra?S3EUvE*4$!-R>hTH>1I`RJe~1a1+6SI76$K4pur=Y7APGq zf@)Rv(w3-FDN-MHTM9Mz_@-u0$9*7m2UXm|_Ee(tOeKY*9%YX)Vvrm(OyGktTgvOe zvx%wG4^=ZDJI99CMw`d4Z}8mCaP8Yde|WevdOZa4<1Ttq_?H<+jfY+t+y8jYzmwt- z6JbW9Kk7!l(ErjsYCgPa{l~j#KJv=dJVduM#1-f(6dR=rPH-Q$#=j>fj@Z;J@=Et>nCJ_1U0ibrj zi7?X=$-G5I6a-{DB5%L6fDG zbbk;e)T^30xWaB^$;A;IezVLC%XEFWKp)N5F7!*Tcor@Yjc_Fa@m&z_^|&VX|7dX7 zi!6agwYWGT52zac+d!pem`#&M?3bB=a}jX(z73M?mEP8M`2~$3>ET+I++ts|*a~nI&QxA)y4)WQw6<(<7S zzE@egu;S9E_WPvXF=)BHnMUyJZ_4ymX?4r2ITz`hzo9VOzl*8@qH{MCM*HvnJ32pU zY3EYMBTA@J>PTJsW^|A2`~F?#Z+g)O`igUrQGnHK{~aLEdvsy)P@`OlQ=ofQL2*Q^ zcR0Y0fEn5kl(ICB$qhlgVCFaTn4e!_`Qa>a8+xB}%_>oB4MXfPVZJv?4BNVwvQn z@jISWDGa#tXOOq*}pKISeWDkzUs?U3hp zc7%a|2Mhg5Dt3OhejOo5jV(`8d0s)p{ytaiwJgMvAdAo#E-1@&8;nVh4Yve z1RO8HNC{spo~-1@x30-D7_FNQ+lrp|reJ@9AhaDyM^sEJ>`S!NiS9}Ux-8tCtGfty9lJYgOSkyfteP-2t7 zsA`X8uF+PuBhc>^f5>je&;iSexagMZr!&4c6|MVrSj#N}q|`bIA9^B)1^ZCgZS*Bj-+xtGzs(q&$@;*_~s?mKf^1iBlFB02aKJ&H` zwL`cKF`qQk@t`=zs#ZdF$$ z17mVqZ$B8SWmIXH*+jEl(1<@IZ^rC#d) zNXHZ=QB{C}!{u$`)}Zn5^y{yVgxC+V#wMs1|h#N`P2F(XKgHdZFSTNn-PA z)PrPbl@Sd`@0p2m8^ZHuW4CVS8-s=19_H*A99a;KRDB=!GHcO^dlTVCMbAZcpFWjE zJFE1R9DGRC(Fi)h*!Fg(8Fat_#Hz>}B*F|_Cs3whVv=jCqRl9X6YI=g-D*&QHL+2t zIr0Rpq`_X8R`51^4l+8w&TSO=6mMm#pp`guv%p2`O#^l45R|>s;WCDRfCFg6VvIOL zz$Kg($;L6!3E##g+pCbbE7&~x7ezw0bNb$0J)yP@oqqOMQU14Ap1oTF^k+7HcWWN~ zE1>%bw6Nt(zdM(4|6!ERO6{2wx+g&M@1vl+aAfp#d$IKVh*E2ax|KdS$W_4~DUX zez-;-=9AA>=3g`uxJv|nkWIsWFZz;89Lp6qE8$1-jHoYtwJ>$BO3T@(kS;dd1n>;) zgg-1GxMRJ^o8?_n6Ikkep8q8i0x+^$PxTR3F?9EAKK2$iH~$WOK%Sn-B7w% zi=ICjJAf;B*6KEL^<8l5_UJzgfZ5PPS$I2|d?o@!gRHKivo|I=OQBn6)&#U$!&Ftk z1tfTU?4s`NGGwp(C21~+72JM!FIDV08_Cy}Sy(5^SD&126py#tZl)V!wm{_`YrNtN zB0&$Fz|DuTA?;VvSa0%R7iRDUhLw*!9H5i)zqsJz%Z7%_hRX-j@MsBRxWDrQrOnAz8vG_-%tz>oj#b2-vRb?xQMEIiKp(Z@LadH$eZT3A$S5paWtvP< zRprnovqKonsoBtQaY@n;+tUGoBZ~jQkc^P7W+Z*YK_dkQ?Ei+75iQpU z<7I}oE+j2t#P{?n!LSa;M% zyYrPP^~oU_QI9Kip!JDTl63c1UFbtybW0b%2maJbY|A~Q7WWP{aZ@d3)R`3RY3{-%jne7t%?}_sO5;j1zD&OA>HWcPj=bQ0kKm z7Y&;n{wqs}VV8?$LtI4t$jc<^yOp%%-MN zg~3VVF#7%LtuKpt2*AJ4VtiO-$$`P0{1sEhV-*+Q>WOM@mQOptj?)SZ{2s@7<)1UU zlLLGr)=s$kKO>xwskT%hn1m04q7!oDu(3f{@Ud)|-BHAyy%yt~L^xyJN_6$0uz*nG zp9!)EPjBI)P0XM_mHMcn?LgBj%#cBB4mt_iB1>u|7#K{(nE~(_2qRg4z|$00r-*G- z&U3qgsH&ZXLs)fe&79lN&`>HOAUGzb3WQ5c%?5+8u_2ha=wr8Rro zP5}YGZqI#-bUi0hj%m4s+MocwQ)5X>5!p4i60u4-OCU z0SA9T6q~YiNomg91Cc%}{#snmocIY!_^CdbiImY_4nEercigMgiQcToO$7Cib%HR>`a@JlS%1hE z?1#<}jw~6*(XyCgM0OPPC(gP<9WxR6eqIPy!!lG0nxGTnC}&GR8}iv!(0c`JTm;sj z!p3HWG{P0?Eeq9Fuh_>-@hiqrmkn_93NH~UyS?$)UpfEjxs>}hCPZs4_EE(kM@`E$ z0cY1L)#f1^l=p3=w{`%oR3|_F0Vxd}@(=o>2jx+Id>$htKSd2E{z0J>Y5^bsB6om2 zk1OXzaC{CeR}S@yctFYcpN^`1HP(y|=eTpauJ0$gUh{i}=_`lX9~TjX=?Cv8xwONm zsX0nxumMe#8aOrMqV>Iu$MzdEjKo*ubsEJ$YyP0JEq+io0%jL*4_Wy^{RqS#O;hn> z5>W915P%*4K4kaehaA8AfO>GhRlfk^Z}L#(vrKKg7#ii2;3N+!VII>Q;v>d=QRGL0_NvHC|Im0@0pG~m2DXs=5Y-fZu z$(+919{NmnTovsugs$Flzw|VWBthXL(TWb^gtmtCnSO5#BF=Kj1P9%?!4L~BtoA2@ zvQBgtoU@)<0b#n2!(OR-t@7LP&71G{L}%WYia_6%0slt5?>Drq=+P>U`rLc6eQR?U zwe|LU#-T4kPwxESw7Pxl&&1L%>L!|Hxv<0U@eGBZY83+or%;G|cAx$Hr&h6(9ID~u zD$P%3|p~phPPF zUDXulscXMlP@JXOiSe;7Si#7tHq)U#v1jHW(9p(CkfV+5-$t`j(AaGGgXl7lN0Y5-gh9A4^(2#D8ice<#1#yKpm2wJMU+H z)eO{~p!!O*hO0O@p)#i12|{2N?3Bq#nlXv{iQ6h5-ShR!_>As^Hwc)jBS5QBh~2!4 zuk}_-(fTsg4z5v{yl0;K0^UpQLd8@PK&D#KTNZv5>m0QXLZ;s$4e{L4Exzx(rv!~~ zTHJEGXB!%u@~KC#`s={4rDm6g@iA%5IR%wQWyS`##A ze|2$b2r$87qJc}R{;Kj>X=%Q6j|q;3I^rO0dul%RdK?L675*)e`?x&hiwwUM?<~Cs7Q~{Vbg@uDfefsn%0vr-BeF_6? z{04`u22b_$VeXV&?1h0t47IrG%UE0*4xem1eM6%hPKj5JRUHW9tN37ANrOD>_1@2Fl#{GUL@CFIm>PG>q>|6$;_a9myq)195c}~OrDiR`JG^sJ(yPkD(rg0GT|B2Vn9Kux4<{bem_$;qV3bq0JF zX-Q`DAH1qUrMH4w=(drhfu9a@jvgKkVh{^TWZ7 z7`3qYh!SENeyKv147;cP@xu z{~bS>dIp4n*MUeC#LyhWmu~X8l>tUFQX%#2(cMuwLw#^r0CK>nL-Z3={7^L1{9d#o zP(a{>&O9cx8sGvAZ9JPTu~>%;$tmmZp0XD06~bRX_~t2a}DFrjy=q6AY3}ooM;b zU%fka!So4V@;+H1nqMdl5d~8EB0V}HU!p*Fad<9ZPgO~*ZeZ(8tf5ClARB;S4>m+H zL^1*nN?#&hkPkdoF%Sd!0fa+o6iFw2Jzmh>ME-6VM2~0WXh8{A0)hdDODk9~jlJSo zu`2e+GkXe|MZQ}oPc1t!>s^%*~_zv+Kzu>085pjr3!9=TCkKKK8+rU^| z_JC)JIbHac;6vwEuHzczJVnB`_%-;>mhq;q*!yHRv&FN(j=bof#1VPX;(uXWPj=G* zG5~YaBkKN3d6{uSICmO|sVp=8rhP!$;qAun#viOt3*`M{1>Fl?0oD%hU@iT{qiuOv z{BQPisOyQ$|3ULW3DBu6Gfp$M?*ZQbWEXn?;g5oUi~JnwBJU#adIDVDZ}LZi|2X&W zEBNO+ezQEVJu((4FF&Jk;mgJv=#)~%?iM2Vls8o%3PL>?m$5m+@;M~i-pYSnRl_kd zNiY578?oq6!D1e{&^%mI#byTw8c$c?STC0>7xh|}CUSt;KnUTNg@x~D&zRj3yFI+3 zJQf@&xpOs1xcj)QJCsw{v)au0Cti-BBaN^#Z)JsC%8i8^@y|5BB0`_VUaiS0w&vj> zflrwUtW|}VQ?r?Sa~>uOJJLSZyQ$c!QwI+w2E#PKm z_(fhnrfxy>x{KmlDTxu>yk>Gdw{RE3FUF;9p&hR&QynsPNzaaifg39}qo|{p_tT(`k0PSvP&wUu(#Ig;g9gp_d~2qK~`Bp{K_|=QVa1054~l2BP>6X~2^%c>q1o7m@LrEO3TG zM+U4qu_1hDUrQp|ot|}>#g(a{nH}1qo?4~N{1BIG=wk-IWWw)t058u_?1y6 zVK^hEi!D!Eb&L*#j|>PWhAlXiB`GccXkry2PBaOpv zMQh2@s6FMcHumbhykAAE<^RM-ew>P26F)G8y9WBw`|424GHD}CSy7>5N)pXIb*NU_l z?*xWGho`w!ey!U6gL5mN6909h^(+}xx3dXp*QZ$i#FCS=_24ZsD!xdEx#s6{c9TA# zbU5`KsHO;?8{D|8o}s6p>Pwhg0d+zT*R~N&qimr`c@TE?*fMx4-rlCNi?GZ6Yv8VL z8J{$7^@pKc#<*`!RIbSZQ-zUuXdZ%IzA>QK9wS$H1YHh&J-%xnN>x#>gDF3Bc z@>EWuJg%7ek-*nlg*u!oJ<6QQ@AOX*W{}KT>}UFru|9>`;#MO`&9fT$Sp85p>o|rd zLXV!=c59fr1X-lY;k3{#=uk=Xvzze5*}MM*!$sz%qm=f|n8ZUUaiJ_G*G+gM{4bcG z1VO1*Hoqq|uGLEIC?75FsR<>@^t#1&)Jrad-=Ne13sRzHYqYIVIX|1dg**??0#;55 z7%R~`!D_<)P5i!FVn@B=GU+!l;veGaTL^1$9mO>uqjqI3JLUsbr;YuT!#5|{!yzu| zkSWF~$wxYX7|&cS z`ysFh15#uK)dzuh9~M}!vSF09+jmpZ%ZyyuvoK@7DCTS%U-0S)nNXO|mAXQt!_pAP zrtBrA5ua&3hp#)xCMu7fu0rQJQ6LRw;Rl0!pC{6u*AqE_Y1s5G173|)&3licFQ4=F zbflo`RCNZKN1=BkcANLF)41zkLR1`fA}}Q)Fs0gmN(bd|31epi>4(Jc%hN&e*M^0m z?1pcM7>`^};_#noTu;WGx9f4_i%hsZ98chXQaxltmS~O0M)m1@&T#Zn&P}Xt?Eu4k z!`CI z*#)`hhZP=ffP#_xDt4A-X-gFI>G zAFHRAa^_HO6!`c~U|^&Us+R~$WpI?eg&&6YVK%V6?q%a`jQ{B@GFCW!J;Th>Tdme% zi$Syfb=TC)1s7=y&k%r>gNLL_B^Ce+1e9L&i)UJ& z9BSbg{x{1=mSm*`z48viQbl|`5p>nADAwNc7huHjIFzNxLcREsmRJ*l=?>WUa z+G)wr7(P!(>3iv!Txpo4EKf-~&PmR>@P7_)6aalpuxmga(6f9$Z`~b?bqMyp8cT>4 zk3rDS4#<9&G9gx5>-G8QSm@>48WN=+HQ~ZOw!RN<=Rwyd@M(Tf!mukpBc4g*PvD+` zVMGp|M5rJ3Ji@!H7QErh?h2*fW-YacC*Z;IhFStkNu|%bwsXbya*7PZnbzUx-|t>9rck+X$;VDGNW6=MG&`#>jZnQVqOR!qV&%Yl^dgIX>3rC- zWCy&pw;!WWLy*O4ZE>BhsQU;SWFs)tq&tjE5=in0_-Hq*(8rrd{8EgyOi^s{E5oEZ zxkQExwD1fdRvlAz8?A#j!5zm9o4he2K(23QvocGB`^Cf4Gx3A@}pY_(bxoH2$1-?b6TG(3hljBND z^Kk;;Argn0;u=}k7d$bCTk8J4;$FbsjZMwDrJC`wj?081mS@k}eB;|2x?}SO3z04x zqtW*J!9G}jB7ZoZ7@ib=8mNIZ&R75aAV-0i7ehdO^!4}f4T%wC{(Ok9VLyoU%+hJZ zX7|<%JHd?%X(%=}!{Oy0&+-v zJ2!k+@miQ-B#m_kTZZCym>XWlL)~RkcZ3sse~i6O7BHE9QQBxw0zV$^%RlDTh+oZ0 zp9r*Kt{`&%%9?ZR{7qd1`77vo-1Bxc(&25g`734d6B0xJ!)~TE2Nw}7vy~fThJ&d| zdX(Bd6gAjZsrjrg!iQ`Z_u?e#32`w^v>P zlKcPzwzLnPIc@_W@eq075hd*-=<5bZ0$_r^3}ZB6=|Cv-M{ESF5Ww-km!+JgDZgz- zhF5Q3)`6w+RBNb4zkkh`&reAAh=i23v%9kNrIDVPU zH+*jP=CeB{N(b-7fP;pY&k&OvfL zTn=Tsu0b~IH$J0XcdI9Ftf~)?#`J>srNPo{B;Qs9vJueuj4p@}rCfb&QRnBfGIN*Uz&wc5-Ox)~4p67}=lJuLN2Z1%M zKdhEQEI$@MJ8xiU2FIaLwtm>!-HbHh38__5$^k|ksSw)S{kK06JAjMO{@S>Re$APE zBaK&G3#ht=BGJF-WhYC`N#F`5!@D@}m5&*Ryu@}g#?Jex$?`<*TPGV^L;~eu=(2x* zk+l18obUN1F;`s|)g6YfH5sSrSvwUjl4dG&Sn>edBbT7ZIsobfYHBp1%xZX!qy|s; zlpQ$r8Ks!LwM>5uzwAe&Gf_FQT|R%FL{L9XgFtSBK!Ghr;o<-4iWdEfT~~G^|90t* zJlRAfpQNj*Zn~0DNWbJRv|AciR|e!og?~4ukKuG}-IztL^?k+;tchp`wu%jqVc>EM zka1QuzYoPH8yC;LX5e}+g%GuqHGYRjESlr@fI19C|4-Bs9y?v)sSFCi(G(V~qr+!xav#{&Vs`LeORxrCsP~xkIPtDF;1ON$MAC4a44OVXsb0$N zz*(%)3WVATkXFv=k(Qx%9r$U*xE2LH=d|3lN0^a6Ku*sptfOpP zC(OP|a183HXw)VEAvV*+4zsOcLyp7GoK_Ybri~mlYa9h>=bTpTivz0}Q7BB;I1)}v z1!^Y3I`T$u8r5lOn%BfF+O=5V#1Q3$8y&j6!u0aYk1&2)A$1wf#9chMT58OBvo=sG zN#lW}u_(!iJ)hEOy%cr7OmT!^p3H$OFjTKrx2!&LtO-4!s}kGcdd(&0w>Le|Ngn?&;LjNL(=?1u$>an72^3Xi{iiFk0p+h1t=c_ zRDm!+0v~9{@PV#V_N!-)%EgCfjp`MkhopOLvVd%k@f86j(0II-R4!}LmohkH*X`Ro>ChpK-KBs!u>&@+ z;hm0@y+Y_UGZ;aoVP1OQE|vHn-w;L%>)WFQ_D@8;_QS;Nc?W{A`;BiZNo5QZ5CeLS zM0MP@`?I#9UHIfko;ERqT*XsG<9ElgOi_Xb`^3@?%q3@tBeI z!x+M0WW1EKnYwr7KX%}t7_Fi*zxL|YnZNjaN9f+I#=xfd@jO*K2RT6zM(He}7KHKH zEv$~pq_!Rl$B%SLbm<)zMhKHqU0$?*m_cgT)v@Ml)DKgP$-Hylp%2$==ba_*1boNw zye~+Xu+?p_$4hym`T2O7nb|=5xi=?`3|qQ?sn6Jl^Re&+-XM1f4pI!Unop%v30fce zpfQg1YVYgRpcgsPp_ega@t2X$9fUWMKYHRQV_+Dt*mJ^L!806t@#GUmoPWSnQb3eO zP+OI&&QJr^w7ipp3g&i-{skjes%koC+-;veJk8!4m@QM^0_^r-!2$I6Seu*#N-AQX zej0ItfA4a*1`AnqzXCCt)I6PMsU+=RUVtOt!We)LYfs1X;;c53c*}X2B5jIW9~1Lr z$o5vbUxSnwBKt{;22;UXw(SdUvLJ216uhbBW|aUqq=B-7&YuH*__7%kO^HPfK?f_# z_ibYYkx&7D>*j~V&nJtC7sgF~Y!5I<>CyV(aK+)$c*8>h>W zA{ih}nI#bnCEAX0p!|TxO1qqu9d-5m*HhF1w?YP8g*$PHQ@vdjir6Dn)s{q0-++zg zDS+xeE87Dg!h9g|R`>!Cz1c+}bS8WtX!|z;)PeW^CWSEeFPH~0*T1PK05Mrw)JO*9 z1_A}{f)p0tK90;Gv^%|DnNiuezvd^^9c!95wX@REczuFSRo|dx!$J+GxFFPZFE{qS zYizEAuBT4YVB?9~`RkGsCDO4cSgK(+uemDukr<0cD+jza`gCq!hTMtUaSoXS)*PAtddC-Q zG6$6Q#xL0{a%Cqx8TMr|O3$Wu&IcSgtO(@efoS{KIyk57m|?9F%&9Uxzo|0yplPi= zAg&&?t5Mwbi%DJcPiS!cvE=klej4hXXIU!xqE3UN=#lAPgaAQtDZua$R{j@Z@#w!X zm=$TYu}($Ag5Z6(LCKb5kHw1PX;y8wsfKKHOTd%!JqT~*28Sc=Gg1TuNgVUcx4+iI zcgmfqCuVW%IH)b>1e5nrW?TY2KUM%&G=JrtXO$~#IVnw+~EXz>OH#gy~kZp@ zFEdugP(!AkZj`1ao!|Ep1`7;qg?*dhGOntQlg%17ba<6#EfJ4NZ)^QzJT%Rb_Qx-9)h3LBN$PZld1*v+nZqKEPwL{EbDqu;IKIGd#v#0;C@=3%tE`Ys7EOYFqRE(Cn9Z9EzofB;X`nq&ZWbS;!w^!{W`e~`Grq09J2dNG!=L1)q5WL z!oquN6*aZnxs06Lt14kBnKMJr(5NjyvG;#VrX>V0l#x5DsA)t2LP}%+aq77v4}#MH z!ESx$whmqjaxEj;PglLV)|#eA#=|G86ZJ34mO{OAEn_6{#=`tqC@&Qq$Ul)%s?uG>4o2dw4+ff9(i98vE7!spoqsbatJx3npV9@R5NFo#=@q$4fSo|qNYwbsbBd&&4+eGR2EYex&GIYQ1Nd#fR07)#{+LxUuKQ-W+^t!LuYqVfi!a9e$SF3NhH_$nF-REZWlG_ z1a4N37e+l_?HK~J8FM27_q!EALA`3$@MK9IC2aDz;?|~q3pHY*Xg-iZ{F^Sq3jK^> z8WpF~6Ho1Yshkkvczfb)ajQR1716uwT zNC`^65J7Z0Kryes$C7;R+F@aIFO$ck?kyu z*2<$JSS#wzp^acD7Pd%q)7tL6SG4hRqkcN}Ri})Xrq*Q!SRvyVr6q%S) zctkb*!F_`|!F>5*sB{;UuvU<9hd39blmy1Db0LA_FRR1)4)#Wp=_gpMLbsAy`^^$u za1duPZ5DKaAu86Gp*BvBSu5W#t?E#ju~y*u9L+Dk+wY`oq{PSnh*G z7>K!Gd#32mt+CyZ&-Re`!pfj`syTtXXW9;vgslzvNZ_5PgxG<=))iRK3| zqPDS=w0Jjx!Urpb3|VyZ85PHOfxRTS=TQz`M`B5 z&3tuTFhxH_f2^{S*&*utPU1cMJ@PNW-s0~5v6p`%|3O^>9RK2Z55LVn`E1)Fas(8R z?SM%p(bm3Ma8YrumZDbs7ffKTaM{xt^Wm`a7sQcFHeV+E&^AUcQd2}MF)&4DsLo@8 zXTL2BXdc_c_+CaL()#Ifij}!0EiW^_Od#X=U~3278KZ)@b)R$hZLyEa%#q%Teiz(a z`%>WeT2O+s!FrW=!k)f7U|*9CHoZt*W6ueLh)IHuRa$cM_c1&Y8XRB(;Q6a(sWq_f zi9F@O$RQx)aAjlq#-?^Px20*wcQq}~1Mw*!t~SObB_m}~xPzacoAUsf{2P0O;iKgiBv!`#oAMD}xbv8NKv@bf zDQS-b0U*mRPs?&SbW-D|M(F1po?xmCSwCv7$M>mE@?5ED=smYz(d#W)7LU(AdXskU z_SzEh&ZrkMSO^?=`w?qi?a1H<&{W+eh;uY_3yjhY8z5<1jvbW6Yv3SJAi>-p!CywA z8|iG5i6MW)VZ^Leib#qR9KE8e$}WY00mDq5i+BS10a%R6S8z9(>TpiGS+?(9p^p~( zH2zZM6)8s8o)-Id{qcW+7m_@(WV6pJSw{&c0vR!x76-k094`hKg9NCM5=A{vhTn_z zY5j%zZkOO}iegR9S9+5j)SCKo}cFj2ft(%xb zjzL?ZS*o{vKT#->4t*rgD8?APt*)P94!sx4zN*;4V`aaC4ccn^o8;a{lHyEnj7~g| zsKb8ZJbgfCU;Is_0-qc@{#T7c#52u1^=qc7b`i`)hZRnfGQI(^Dy|M+h-^<&U+1#< zqVD#B30}j{CJba;5YuRT_5SKR%ah~f$wsF191o<0Ae=7tjT z8~70brshGuJ);9C<^S;EtzD=i^#O-c|p1>O2_-0saN@|+%f<(wit;V*fbp*u5<-tvtA(--N( zthCFTUlCM(j211JMeuV*(UI%RZ#v4=iX(&bBLdjrTj^}W88+8X@`!1&GOTMhqpJ6f z+NG(jk1;ntUz_Ts$FB%cjoC}p?K@bYH~3NGx%+*_KTOwA77SuZQ5t`f$$~^|RrNjD zTYrd{GYyv5Ab-DElKHHPbkxeAMx-(|+1q-mSwYV)VTGYulvLX9s|TPpnYH1ZT*~ye zJ}XFZrk8aVawbbOjUYXX%rL?AwKhBL^^e7}GFdrOHbtwcw0x_XnP6J3xvk>LxCSml zUQdq22WGsA0Q<-N4*n!1@=!ifu~&W%H*_vw?gSC9+J@_3ZDZ4+&7L- zf<0>daZL1`&mknFjNgd16*yLbNiE{qNzb)^%1FAJ>{V}=Bnct1B6J(KA{FcfL#kyArgl^iw{RD68E|Id*rdAg?`b+# zxU}G1L*%Lgb<272@e~)>pzbjDU%^p~e9S~gksaoQ7X%;u7C-JETfeNJvx9y6tCWQt zH`&Yw1^DW)0cSf>dXsq4#~M2q#}HB@>GaJdR=Z4Z<&5%Gg+&0Wcv4_*_^?jBRc#)g zLz%ws124mG4pel;de#DjmgYFuO#3FUne3XW)|4E#KM5AhA)psAo?U~|VLimN(FYsXP{b%masVup zbRKWoB3?t`?1I>K8{{9x9XUzj2%4^Vk#_c+6u1joKyk`17|L)0eA7S!e1jpVbVGHF zTIKqt5prbS>xp+*UwnRu=E zTZV|4S!BEmJ}y`}4!Bk;lvNoXma2-X`MTk*`okv$uQ^H^1O_n%O#`Vq1Hr^X^mF?4 z62xoW*YJRruxZ#gexIQRU5MUM?n9r36MkWK- znu%7sKB*x{_Gi&R6;ity>oq1i7zjg;!n1!0T@ieapMguJQ3T*MY_AYyIWGaTRNn>OF#*~4SN6MdEV>2zIQXsp0#Gpn$2O) zoQXBRB_5=l4b=FQP*LJChfR_vGg)$&0jm!)=TwC1%ocoOGPFD4@C1#FWmVqK^dku`fY+x1qu;h& z+=b{oT&NcLT$QutNc@9QmAvpo#$4UXH~1Z_7qfiqi=I5eXTpz!vyi(4+&RV zRC)y^vEb^bkK1=y`33DR5(U?auSSB3h3$BnSLE%Bl+z4TIq^66Tc)XMiv=@0#r1zp z;1;u-INu(<-O)>}ur$MtqLPGdUAih1+SN~9a2ZkT#T+&dBqfN=ZU&1tXl3^Yn9kLa z>fz8J_L_alVTjcfh{s*B2qvXScHd)2Vn5if)eYKvwHcHfw38---SF;woM1!{Li;i= zpm(`aH__ZIg-5=&H-n)@$|<{t6D$n)G_()0q7}7bddN1vR*r1+GhXen%6j^oWjReU zIVu+_C-g%e;1^l?nI|J_j+?cCtz!<#vjFUx!3&IR5GhlC7xES)-hA2EI?m=V6qIhj zLLfxeEIr`^()qBNpqcSrz>Lt|ERh6N7l)&gig*@SkjY4b@cOxI#LM3Tw*8Q;x28@zEb9!+ofm|kMm22Ciz@wOENc0y?zO4=EL zTATMBm2FspjXTei-W5+0uE}k8%dn=rSg`H&^dmu-4+wZ@J(}7ou>YPTsJoDm3Ju1E z8kU_E49eE9reDrn=!2^3eyH|dR%OIe%uT1}5etP~gV)k1@}49{2!zQo=>dRB$=xw@ zkJemFAPQ^QS1i@;hU&E{Liwr=SFq`i&;riZEYfi1Poia-2q~XTuA~5?D!|I>Z%gN* z2W+v#!!Z)_%+vlFWJw22P#u!saruj#|HCxxHtW0g5CcAY!i!?qC7O zFie6i49ZOKD0OK{yBdry{vtt;vZkDKjIWE3BH8Cmh~v;ChnSO6dvJ6t9a>#MXSc)uI?G7Bqpckddg043F5W3sDFIW^DLi zOAI}llXbbVN{23utUVW8Bqp@edlhacMBxg~O=*uDNsoeEERI)Dq{=bPQm@f7F*~7~ z{ZK^*N!Taj?1{S~R#f13Nz(Qs!=SlX{CzhYWk;J{=2C@ha|#(@_c3`wcz}HjwRC=d z#rU-~sl^tb9-Sp=wYY`?zk^P14r`=My8$ZJ6hOWxXRAo-a2j2X43>g_4Xt^!1(H0!<-* zzb!CT)#lt}jWmcv@v$Yxs4$}*6O+?X^NXv6rJrpaHG%=$O-vVPk@H}^-Yz>xW?UXL zUNi!6BiGXS6R>_}L(Q4|(Dfp%htARBu~*ycxHI7OT>(~quL)f2_Cw-B;crmkFV;IH zz-9TB{&nVK=3e@D6R0$$D~(_>yTPHup)bwu%s!KX71WU>T@jQjDNFURk*C~#l)(a1 zm~Rg_=CtT~I6Xqc zdQ(n4UO&}gUexWsKhOO#Y}P{X^pXM%pqVDgOW|>3`Q@tx(;;^9R@foixTVh+Po{ZH zIvRmB$TD09c3_Hk8(X8<-d&FNkUD+!X2xM|74D5&4eLD$qP1)sScH8M>(%EHL=m-I zz#)Q3l!D(Nkf6n=AamWf9`rGSxsB5a`PcSOLj^np0m;{7Nz-LUk11$bOiZ$l))Qi| z1_2P1p`s{JAP}4#gn;?K*JnfMi#G(7>D?q7I@db(Z71c~#%+nvzadK~d|tM%f)OTj z4Gu?HWDna3>&zo%_#A-jYIC_D6M*7pePcFkLPWDfv&8oeKv**QWpxDwCeoZxdc__N znv&-;4+#6TfcQXu4w1yeut8+piYdeMPUxzT&pPp8u%%mW!K5}k%1k$dr|A}E1xJ|p z>gH1i5u1b=Da;E$G~C2xtJ=N=7n5K7ZeDj2Ja^YvH^??$Ea1K;yBS}5SEWOo4H^+i zJ+gj&gd*kPG`aCPC3*?z@~#0~<1bw0hO!9p<@LiPk>~_T!6}j;!&pX#BWjF?{SsZ8 z3sj0@Ko?gx_O)1bc!D;=ZSpuNwsfZ_|M6S1WV3h|&Ll@x_?(cGcDBtX;^FxDt&j}C znO{(uANWG}Dkstr#`(92_YD)6uJCrgr>OL@XE*7lx>$?~AYJJLM-AzpAX;Hz|0hH% zG&&{>EEYMNC;-ul3ul1$e2x$hf&}T7J`&p#Mn=_f>rIFde^gsj2pwpLaAEBvL;hT% z6yOW7a`2StZ;h!;$#Qp;$f zS7;{qE{V;V%utP|+|nXDiSD;h_mF3mQoO8~YHs>=Ee( zgvHJi^FH{7<4>!5T3$hs-p;2n%qe0uy=45@v(f^GzNJunrg)XTm<}OvrntQ(Jh`GL zHl>XSD^%K-7Tgt&gKYj`e+pZnzabUN&_cKSES`X%FgZPe<+ zi1}^dXw^p2+mcgZO!rZ|udTxa=?*VL&PqyVTo`L+wBGt#)@i73=m!W|wDF^M3|G^; z1`tQSydP_*0Ir4x;8yVW%D-9t^b`&(|6cmfd-y$W>_PKUw}w*j&wWBa1~8I zc-~smMb%;@SnVLe^&skqN6D{Vo74mV7Ury8Go@i&<76?NcRS)coVEMxOYwbpKmg&J zn>xKq;PPGQd*gl@b!b{(uL#Q@!qb%(mrT~G7AFO3w z(e9^c>yqc3fg2)(RkOG0YKfSjzF1PydQh^@4C2|M;T#^k(B0|vHXxfvOJ1w+> z%?VJ4GT;R#kwp$qQrGOoWq}`aDx~kcD@j-5r_!`i_4DK&`+(HP$v#cxcC3E9{~|rb z!SE)tgWXyNtFQX3gEI@z0K!M<0EOX0<4DTLdfKW55?ms0}C_IHuXo|`uS)qf%a5KC$2{u@X1e7H{#3jYo(ZlQubC4aBHQq(51MyxQx41z(Q zJMT*-go=XIW`_f z{Y5)h>&z#+vcP(x9jW*FD0`Y2A$iEJW?h!Ld%9uJ9taj0-~kp{w*ID_CaT=XM9QC7=NUu?^~v zP~omtOSenM2s{M#d7=G)&ca=M&g}Feh(Po#N?!wtgiV~iQ^l4biKHeCsw73Kwv|Qu zO3;QLohSVU(RgQH78j*%h%PF;O^^bc0Te8RF9_cn&U>~|jYCI2oRcPSXe-EF?5LcG zM$tspzL)K1`N?$s6Aqgz4m*++X?sEM+@0WU`(iJ_kIp`nrzQj&g` z6~;?I|In8wxJ$>vunZs0Wu}o<{#;m!f$X*0^yDyef>~q`ShS-ZS5f>OwfLaXi@u@i z3!zyJ7dA@&I3Xtc4?@<}UUot!+&)d1((fJH3X}N7XDt>?5-j!?Ot5NBmO$B#ia`JM z9IBKZN?CeLmM_WtlQ~*7#qQRd<$?c|8k?bkM3;kO1=~{4+HD2=mb5lxUM$8@VSv4) zgM-+uCiTvw>W{?ml@N6vI-4D4KGexN)Q@i=G6t(@f|>EzvcRIlG&KHT5bzu#iOa#F zW0MUtJ#wDUE%iON& zBSRpf&F=`X@M*6|dXg^q->VZGGuj-5G@h z2Q4z1DmNFl*oTOu%x}?rq~u5Ow~8bzx!xvs7VKMZvgmZ=9=Tdxk$0be)W_@lSJeBI z`rnZfR?mND^l%iY^~_AqOatMc=qWutGXq4=@l)zK0Ey@8neDteuSjFK&|nd+n^jg; zhfn3d*F4sThCBMk;3&d@2{=z;o}VV**UcY+ocmLow%;ygUaaAftU|_qqG!aQLBhai{-=ya$2Ia15 zV39WMjBp}rvgOvn6JEhE2BR#Mf+$EK%YJOnPMb~ zhyiX%#eShpdWNn747AUu505lKm?iEj3qjyF_*`ZAR0Fqqc!BG;p3glB+26k9))T7+ zHKLW4xy%#6`i^AwgO5*k4v%6GzOoIKHAQf59OJh}N#^pO!C|=xCIMTX7wOL|r`x_}6yQBovRiqB8GYmR%x-z@lKA$Q?j-Tb;CSK%f$t zS+v$iXYs%6QD^fdMLJustQ(zgt)q3?hVQEQkzHdWsOq3@$xAy~uXP>z!$k9li%Ym` zp?7VZttczEv%f0%T&y*meBB~BEHQD#z5jd=bV-34toa^c0OT`N(iF%wB#LccW8441 z-=H)=qJ3B7GWLG& zH;AvVN?o_IPB*&1xSG%2f<4kRXh(m_Gxe%H|ABS?v-~gpZ;y#rfgDeqJb=@#@az4Q zXX4d2x7}Cjk;+$&f{2I#I1o4TCf0o&Rm^22<;jk87q?QGX5I#uwV0C|t!I(}JMaAn zak#P)>YvhII&h=}|IEJy9o#@8FkfM-WP-f)J*VK;-$z&5dd<9rKk8@5q1uCo zEX=DD9Mrn+sK~j{&m*wsROKYl;ZyEd2*FJ}X@ohkR#PhRbG%N!lyUqcmCgnX3h;oM zoOReN!l17Dc^~J!iVg?eIc=)SmN?D|tnmE&g=JHwSK~b+we7R)Ws{KjCi#7df!=2C z;JAtXdLqVx?2I}p{c z3V9o&<`LS#@0CG}t$`8z56{x+UzO?y0XPSrluCZ+mCk&qR5=iqDf+>`3b*?y<5yME zv@J1GBm2A?XAgyh7;vdnvCY$K-H`c#qZ4xlW%ffQy^#@L=o-D%e0lhdHaCP?X7vRk zx{F^G{FpV%k|^;Qm7wR;b!Oc|=CO#)o&J*N(sg?s0l=w$_;TECb*I0$q1i7ua@}bpm#<4N0G^NrpikaRw28|*VF^iWQ^QG)%1I(YzeP$=sGS1kDU;Gy1x zu<`iO`A{zhrgGX#M8P%_hHvdZ?Ny8#emm_kBX%Id<%Y5rpkte_lU(M{ zH2YdcE1b76RuLl9mF=&awMI-I+}BdLNYoJk^EnO9+fKBXw!3OwV1u0t%Bc$d>guRw zR9KMf>hEO5fPTCU(Y>_1>LrVLdkqo!O(3a0FMDuFr;S!!({@g0X(fGQOlhdOU`>Yn6-)XPLKZP*DvT`feE@i~QCC-XS3V7` zpZ^Vdsh+VVoYt|o2vrd%k>7O;JGuITUq)U>f9Tr_I`S??~xSM&hjj<*5gZZz$d)3-*We^?e(=%rZ9KA~x^$I~IYp_0BLO=yUdE>f;>atRo7fH`!+X z*+tRxLTo{CkraCQ6!en{Z1f%3+|@A3SD-h|OluZd{#43+NI+9fS=lbaoADj9gd#cI z67%V-d!#yjt4S?7SVn8s$=69HlcmbcCA@T=#=)4q15P>W2g298lmDEt1HBOHadZCR zKhyX>i64ipeTD0$w>AuL9L8s3t%q}M*il{H-r!a0Davl5j)PXHI=6S09F!b?kTL}h zE^%GK#x6nTZS-Fyh8sk*TS9Q6lRA9YSIk?DR`82KE+GSj1W_OwXh7UYNG z(rm_(+d|c}`EN6y5~^U(2R&Ws4&&@=dRSBAQ5Fg3a0_Rb5XHi5_~^a9kVvR1J@s#T zV)555?o`)V2`lAR;iDqO%_Y?V!lm) z7V&Rz-uk=6CV$V(YKJ&3u6|q4Qtj?2YtPYsWg%A>0DklhGKBQ@Me$A6aw#dtGgU|= zg0r~T&+Sj{>Mzmy?q7kUt_=+4N|x8Rv{?~nPNL`OTgR@n2xHlZb_*fl;E{@+)`DalSg|6wU!{9Z@ifnpAn1AOfP;6jqZjmG~A>aW6O%! zGblG8XG@a*Q}K+n8eA{H%3ea6y=O*DWMK%q4ULmytNyM6jlnHm8^F8PhguP|y3pMf z_SK-D9>) zS1(bg&x3f~d zxOrdf=CO~oh{1_=W#b;JtIwCT;AkSZ#aAJhUG=!bPw;7MKXusZfL|KmL7xZo>TfQ- zJ3OtPl@I?Ln{iGjYZ;jR-{_PAJO|(fDfy!F1+GH9$+ih74NL{Znm5i&ej1CQVGQES zTR?k3|71m1sTfML#84-(O{06cb4kFUhtew zmxChG{SY$V3iFv$uLF@GI8cj+MkV%xW5M(*+N(9CJbDjCVEtoY*adg9_xjUZQWa`# z{#0^J0D}*{OOA@Vx|a;Nv(u!;1t#k3zD>s4sZnRDdO*|9lnmi>53AZeRULcI6mr;E z4OJz70YxB*^CT}fKhGoKqmBoIb# zXNG><)v}J|dpt>n?vi$p1IzY`ppid)yr7Iddgz-V|LN#{gE66#j3~b=x1^pva5&~L zbHkD~um<(?**&Et1+CW=p?8o4#EbamY8cadO73B{B^#!z=a45OE>m3od|rzQOP1QH zfl`9TPJv=BtPuN5bo@e0ji@SZZvBMLv3V|q9L{J6o522sJD!6x5~`|LljTe8ABpz+ zs38yP*<558Zi#s>DVF&Zln8dzwuG2kos@1x2Rq#HveM|nc`~Bbm7-Fo_UUv5ibvd3 zy#=A{HuRFg1FcBY+Nf$d=Y*9`w#Y7MVVS~w{ghQd6uH)jq_8YMVV9iI5uUMj2q7%#H@7lf$D02JL8k3@(AO(c0iY7?8?xI9 zhixC^tL!lrg>&o93$C_>g1<{Nc{0qW^~R;=dTUuOWLt zgpz_A&scu!>J42dX0;y95X)6vNstA+Bo|Z#hMHbNhcklxYV3m3H+V&1b;L43M|zVd zsA`L)KLTH)a<=gn1K^~sgEJKV5>%jIQ6yMq0{D6Vpd!P*KszIGJ}63(B0j~AK40@i z?}#4CKm)^mzbd1us>OJ6>sJ_66II3VE+|F#)9lOm5mXb>+|tsLz^;|jAAn2*cvv`? z=gq5UfJ|r%3YC8VGN0fv)xh>Ku>eA*YW5^7wzz?J&Np=cuF2dg;51eo30Is=$EW+#!>{#rAII(-{S8p7iZX`2G`~U^y<sBJcQ81$Wj{n>qHhTpw|6jsI zy1QcQ`JW(t*-N@Yzv7nbz7f6~BHXHS{B(ET4dKoB7uR{4XdL^!kV@-0ApKABxp<+T zY9+&5#&d`}9CF@%dJsVU#-H#<6QJz>9zJcqd@2J-|7WGorT%99`+@(n#;1CLhb+|l z6I4$b=Hd?j{iH&cbRl-vTn<#5iza0ICkW& zfGP9$tIa+up@bMgPTYoq?VRwRr~*up#h4foAemC*^rR21i-@-k>_I_}wF=>}M&Dz! z-d%{oZ4^F6NLqwQYGR;J^qXhVId>;*I4%=T$kBR1>vil8^!hynC`YfTVSY0y&?Au$ zLx)7$V2~oz-~@KejONYP>_uY?KEAQx0Whn~n1AspFD?a9pBE|95zi};Mlg{522np8 z{qSwP;@j8L>I0@#78;FQ{b2*(eq~m!l0YT^@-!C96f-7u<+%5eGU_6REqx zMhd2tBMi~Z>7xz(z0atJSAI!FqMT*bS+UyXdjzz;j>}{-L?Eg`&tJ#FTPQ|J45B zr_uSs>3rFebj(O+`mpfNBmgb*F7LgDZrmQrHhX`aCt+7Ngm-yN+|XcsKXb^9I+&Q7 z%aqQjndt{aaXB6U!*!OHO4uDslD&G4P|n}s(4~s(K~X~tItZzYSrZ&`T-Mo;qs>c| zzqJ>lZ#jv67Mx&Vt~`k74Wn4f^oQ`<5GQ@$)_@T4J3$uhrOFzWBAn z#Tx#k7HV@%8r-EOJs$u+a9~@Ei0uN`?V&d{h9W&VEc_ef`CxGGQnD`{lupdY_8pVT zSF=Mt%^8HP!;0MvW=^oEZkgV7U~$y!(Ah;NoGgn;^)+5XdiQ-O=)L_LO-52RSBJ=2 zl!m5ZctzwSQF*qL#S?}~T56d@zC*`wxs3ukst*L3Cc9}53D+^{zT2OOGKg;k8MZ7d zN_ir*PNk+A34l)$q=Wt>f!gyo^RKMSpMuaV#83QxQ~(6+GCGy=f9e$b zqx?xe(C|O2`5(1Dlh1WM7kJkDFFk*#^=Fwb;iym>T>nF&?208F=WkkoE49ZlpA!jq z_Mlyts?|hEP1zAa$%_SG#(M;*P6j*a9v zR?cqvzGaDUp7WJj;%!^+7;lW14fmvIXCo|eS!!rO4jz8wV3w)M@vN<6V-w&LQI01@ z9T(P{eSdfPLi5u*4g{O`OrXZ8Lu%LRRT}S&6rK+-3MWuMi`YK}RzHlvndf_4TRCCx zS>Yu2OLD@AsUHjDJGpU4=NMWN!QXM0HX>=D1*)(@G8?&X!T1{zig`n#AH(#52P17i zPpf-zU?HdI*@hzE3&SctKy%DveG%7-jkar|ix495F#FB_vf%D6$1 zvg9MY8M!q5J_*!3mfFl%9`0ZR03zy)7TkCe8*>83$Au{ivZ664ev2!{lI*s8{qfbO z-5I$qV0`p0^s79=;m0(*(4-BtzEaqJH{*{@X0avYwINm=j#Vo7d+%?uuV4+`iU$!eW$ z2)Lftz>CiE=F#gcjq7)07tMjs)(sj$P`dGW!?=fK;K- zPE{QF25$#VYGkfrlDSVq)#LSG7*M{JD;6}s_XgcY0ouj#UfdHmouB2U4*?~Sd*rW{ zA$VvADVHn@J$i$mlc-su$<8?m+&7N`XuA>F&(9!*?Zgw-fnH%|c%&OzO%&ZklxTP) zy=D9Fust6>{7=TWg%nN@Am_r;JKuu$T;%_fL>I7nHxZ-Bf|j2g9P21m;cBNTZdxAe zH;=sP%J3URUbr*JzOyM=$!=c=1CJy|qcBX^krIx)Ol)42R}MW)#4+Xy5p&>)D1+EN zJ&6*&lIrq}6f9PXg) zSIZbko3;GT^4BAZH}RpP`N_GdfT=dnLL3<}_zr+UBnnM(o|cp-dzA7709I@OZ*jyp zdqqr)+KJ*Nnf$qC=m0OCU6^e@xDc=B(=Q)*Xmrs+v5@%*J+PETsU8+v5*w9qjfHRA zWbwxJrZG>VaA!H;vI0oS{v$GxK`|`*?j!bYYugE{`|O6L3uAWjf|P+6R5GL^H6P&t zDrAKUo6BqZ`~ads=8!4yX=7Qu1GWENZjZr?y1CdbeM3ES*mHC0e#ikqzCV{WP|F<7raZF8`^s939}u02-D}nL^4~TFP%@plX2Gu9bG( z0=L(dmI?K>#`0P|n+_DADXnZdXjaD-LJ=I~hllU5b^(TRzKM6~m$HpAPG~CkfX}w_ z7t>eef6e05lvP~U%}d&=IN^*f2VtThN%@OPqU1-c_u}zwCI7FxYb%$|>V=71aD5BJ zeAo%r70SyJy-s`kS$h@!6JM%}QXh>hLM8fK`Syu(2+4l5J2I+Z~f|JSmOcq${V zHP(s}x%vmmrO73ci-tfu~6xV>aL-@DoqFqk-VZ(lxZ@FT7bUak^jJD<&A|OVbk3~+nu@wib z(m3}+8V8|;;2`P68tR1(4=Su>jgwfZd72lUw{pVnS7$eBsti1M`G28&y4~!HOAPDS z7*KM}E96nGieFrzfEeTRV<>a;Iew4cB7^R~8G;u<;SxcC)Q6@?VR-6f^)u*q%stA8`cVRMtslP>kF8@Ld))Oq|KPO;)}Os^y3J^ zg(zh!=+5s5RzCkj}US{++Kq6ezlT;-h9$V^I~}UN6>!_QLe-H1+;IOF$^`6oH-!# zu0$L38+6}gC5i%qC_({^z|$6FJJ<~9r1%dOf1zDYb-<%tP63E7wBPdy;(JZa`Mt7y z5Q=nsEG^+_vwSQexVgEZ(HEoz-(?d?WP0Tqhio zYToUo*igk^sB`zfR-5k_si5>yFv*`rLISHk%t9dV2HiozMx>>9j2$u~K}eAQf)l*W z(13Jw@GP1%VRlS__pm_v)+N|E^Js-6|BivAdLCj+pr1Y}_X9#gGqUd7LPs2Y??bwu z7B-=15OTkBcBMa6i~6h$BMe6GW8pzt(YBxMxul zbC4B?kGB}I*@GtUcD$#&2wj%r9xWb+r~RTutR{SYu6z@SqlRkUYih!-OxJWA(6`-V zpR0|{6ELz)`%AW95`K|rC8aN*4g{M8!;ntr2gY|~OGS6YDs#mV@sj(<@iSQ9EmL~> zp0t67EO|CT08%;HwFk-Qg|v&(Wb3ba;zXpA=T1x71d@C+OUfngJ@BR7Ml{5m6C`lG z#QCbI&oX?QXtIrHs#3A-=|0&eaP?mLhl*}{+;`tvMfTqy%R!Sa(y#C|iH(l+{1E`K z6km;x2Z=^=(A$LZ-Ucf45H%1p!Sra36^vX?m!|S>kaMEF?OmB=QV>K+3d+_UGfuW9 zK^VkwDEej0g-A??(S4jqNQX)wuu#KWU-3dyc&Ca=+TXO47?&`1TjE8E!T0KAM2hWb zLU5~9w6IDs7n==I*OIByR+kfCWC$KO_Ga$J zq-qM}i1&ilI*0uld@GKaDalC)-SW0%Qe>EkAxVt%SM>TgHHylvJ%BZ^ru{B z;}QhO)baB;`kC1|;bq$7(tO_()b|{H8gI{jVTKc8SoXifYp>mE)>2kV0xB?m-UZBeb z$)k{Z@pUzKFq&hX3;DGVH*uFeR8Vu6O@5!3Yr)zP38W5^1mokJz_)r}L|a$&QPu!W zc1(xL(T6>IW;EBEVzklE01SU|QXy%&UE69kHn*C*+>+>UqC4E=%Cf!O67Ph2?EA{G zY!Zckw1B;4ak+a!ndZ#ets1{SYLSx}%Bl<=kC2JeW+a@Kl(-Hf9+RpF(%Z*`02UwS z3YO|7t$eb+!v_Mk3y}}kgCX-lD-d)1iexI;ptuJyOJBD;ri>i7dL?@1H%OtGP!sEv zpD3EBj{K8yZ&6$*S-B5U&* z%QU`-DYQ^xw-(V>(wsE=bcRf(+6qO5;j?CjO(xe5)bCh1Lx4yp z)EYuyEqy-SPEqklv60RN_@M)Skz!l0o;=P~APf$E4=U7oCY~4oXAW8wxJNfq*-5X3 zcM|<8;loRr?tqFoS&b4kvR-t&<1dY5+iPo5viv?X!<%`^TE<%)6&hDOfWD85J7*0i z1)WvVQsa3C6bbB`HcC^cTrQ`R_az~;pU`gOS&b6}geKXM=i&DaPCm#9ytTFZ&3X6Y0Qa`9}|g z_;VJZXAR*3gOH+)Nos#PR@JkztP!#7vxd=vmz%8+xD#Ge=)Rsvs5j_ zsJ)kg!}@>9(08Hp|3?y0eGIhSP^g7#!xL}b^8s0+^F^SIVc+|%8QXw~$ZION(;Ox) zY`Df<&9^uFk#|au^3xHQN$84+Nr5;GxI%oceuIPNDho$F26p$32TU$tdnmlD4oBXv zVRNm@#`HE#+4y!T=eCj>e2Cu?7BzHu_zqvdMVNklK=}4+>Yk1hVQq&$Cf44der)K4 z4ZXs(&(|o=Z{eMpG;idJ0T}IFI&2HKiH+{-qyvADcOok`sU19{(s!Brc5ZrZ(eR(1 zma5A#_nF)bbZT7z1s8{hWkXnQ^6zgOjYkMgZGJX7YH!c@3H6pk+}O0iGLM&((xq0u_=Mj z)w}ICvF=eo}U)VUo zgEZdx2FqiNvH0f@e-z*?wG6QwmU#enr!KlDs>-!BvbUWE=J<0?$_r{u;uRe7;-3UE zL!+?2zQiJQ&J4l4OJbj%wv%+k-W=_P4HIV`N%NJ1CX@a3k=M+-ynn~tEx$DM{NXhkYWu?p$U<0zVUoU) zwAofmJ5P8H<}D`QtYJcxCyjBp!4WWy4aA^hyv?$54mUrw&r)G?`h2<_=|;BB6^Pc8 zt0NjWBVIteCcw;wDqs9+B2HUghhD6X1e?K}ol26Q8E|Yx+L7s3xI;%uZ`d4;$Hj#1 z#EWVj)5ZcD{~IJB?`CRsy5JRRQAl0z9(r^wxfP2QA+?j!jrNI3ZN=jCF+F>|@^4mh zE8qV?@QHi+T>R<&{Yi~0P*7n7;fvpJ7oee)_9+|C_r!kI2ii1OXr*1jdJl*!b=j=* zoTa)cwDFwz?|%X2kre{dhvq9xoy{sQ4q&#Hh*yn_Q%=3JJz zYuDCei4dhZz?MRQ;uCH@4Vib<@o=TIQyOH5aOAQ4O$s!fHQo@JYXmW1S4G;DiEt8d zCgELYRN*>HiA=z92mvb?Xj)KEoii$)R6^fIi{!|^-efxLm5z z5@5#`pHRQTjR*2BHg99`%@-)$TICV>9h5foaFA*90j5~)s0Hf$>0#udqaZyW-7v19 z{8IXb>G`RMWqqc(^awuxN4`kd)RYvbzEX?3Q8%Y*373*lfHExh1IU*8t3M z-!a6*@Sy}xvG4ZO_OpfSZ>q>_%QEyI>s250coWpNv<1k~m(1#HvX6*2HnIlm0@TO2 zO2_XQ4F^M((!p;q-L!3`O(K2wsD#7aw;>^_QY=g2Q_Y>=CpMs(*Hol{CD&k(!w%t% ztwion7rq+49YjJRSH)#JHleR01Ha!pU1Q?WPj~1hKHoyaU-7#&8F3=6^VtS7Fkmtf z9>uJSGC7y-6Gh85W*>n(a=sS|? zSM)=HP~?IB53wz>>B!?hF*}fMKd`w#JCM14QjPT+#QCpsk;m*?pWleo^+$bv^BYux zM*f%wRQDY74m>@^(<41|fT~r2O67Jnu-2h|#mz#dSi_H5buY!E0-mlcr=2mfwn(=R zQH|@G%)<$yF6vDPX+K*k{?f|cQ}{%x+9Yr^sJzMcl4$WdUD2YbpNW-V!jHG-1(!WE z1mOKsq;CfLbHe8^dJ?Tiwk&6}+b-Xk%uPf*4S*&5#QO0M3+*3P_CHdVzN5^)K}2e# zWdI<)@`u`cu-L))dHqx5|D+lFPlmwXj6b|~jo?j04F^6S{99YsT5WWfm#QuCxolT3 zrGP~@VD#N65~X2o$*!D@%pq+vZCa%C{WnPNNUitvRMH4(HxuKUsjl*d$a-Xob^kZn z^9;+|H}<4U2RgHJ-Wp8oD(E?=PP;vzOP^r%K48W!#=-mTHT{{ah$dARy_&2NOMuQ& zc|ABtJUl&8K6Fsiz2i6NQ%IHH!pjs>u2-jgah3@13#w{_oDz0l(NU(sUx4OR1yQci zciv-2TcvFH!b#^wXEgOf=0FqvQo&u~2Q!q@YMjWM4x(C5 zDG{(E=F>F~UYRT|rV|?!9-bv0l>0VC;@_L&wS?i zv)$YvfxPHaqiw+^Z2?SRqB*Ac8?;RJ%io|^BCCRO`koZJb;3!OFtv5;r1ym&Om+SS z4>hl211B!M%NVR_ z7+`I+e%cxFGF056mxfj&zln)OPW0HjIiymxEg#6T7f`Pw zBpuNLKaO7!fW2w8MOWkBS>_(9q zquJhOIwYqqslpFh92=ub%T7&Oyu2vD%L!srO2*1{>RWIRA>bc$0!EIJm>fK;a+(cG z$EjqpKS{OSI5SS2aZyNJ7VxXqezyJtJxegAMiVlNJzBmwqlKZ7<-%(M)izu@5j!N5 zPgHJL(O!Ne8l3?gVBh5$amG3|#x1m$lx5PrIwqNNGz#V<2;J++mF;p;BP)EN_2f&6 ztJ z$bdUaE}nQR$1#fB0${U#q@@9w+my+vBil}V3!NYyKS+f_NI24w7)8Q68);vy%2paH z%)!~@8y#_$<=0p+!mXW=5**iT?nF1CcOwj`@wQaZ?xJk*%)1ITz3RqN-<{u}BS`?e zqVVO4iPUQu^lY7V%aQUxoTA9QHzf6BP{1D}P5A%ybsbPmZA&|pPy&f`sVWAg_s{}J zFN*XchN6H#q)8QNmy!UXgCbG_(mNOglrCLGI!Y6eE^sM=C@7%s1o3&_`wQ!=tl8h} z+2Jr2uhGB(u4!om3=Nb|GuLAoq+hJ6i6W?~^8o;Lx3%^q3m zO839(jIk}M5u2{Z&C8<`iyeQOx+mI~hYJd+!54P%!Fb2}1NXoG#sMpxOFFBf7RRU# zU@W=Va^L5fAKi~*H0jjh@d?F%ri_K-aq~+z@QH}#PoJD0uu=Oa#2fyGcD)V9wq64o zO=vZ^c5^Xn$<2=AYLQg)ax}t4Se{hK%>UxGp%@U8LKmr^VZ5F-cf?28I_KA}==?iT zb4E9sKk=WP*=LH|4uoj!wSeIVA4rpqwnDXk?6u|x2h1ADbbaEdncrth*!o9@7T$2} z_j$a6c0Qj_CJZHUVlrck|WZC;$U9YEx#7lS>tyfF5F+bvMc*5LK!VF&%>EG?03?8j5QX5fPUC5-wUhSSy=(qi zd#8+l%>4v`F9pvk&E~a6?+>w>OXw&g`|dVgsqv=jWs@+JTl>0^jYTbCKiyJNe<{r) zZkeKBLh5$@9hU!fD9AVR?4G%v+wBLUsR7mlc07v_dmzH1&ZS0g-I^V9g zjvA-i&$OeK>pSVt8fDZvpos|T6cdY*S>N!`Wo3HnZ@!k>hF!HrjOPi3*)Qtn$k3YW zhPDB;UX7swUQ^9l;Ts)Z0d1@y*Ug~X3{eOg9+SUcYFPdRna=<#CpVXi%>ypdkuN6Q znMAe}xo?}rPOh#J9>9g>N~&~&>44gJE6H*r#ufx-Gu16YBdo@m$r9e9`|@~P_y3qMVFd$1nOXW*_?pt?m`gWM!QQkNrJ^Z9RYg`TvoF zZG#=jfAmxD#h)BUc*X-+-Wgfmue@K6$Had$DbvKEDY|WMnolR#)iK|>Y{sF4D>F@t zOZl!38I+__p;0mtr{gp>7Ze0U`}uo`%F32c#}9A%=F zTMzwlzSvaqVj9-oRtM1{376_cH=%O-L9a|BdNY)z>yprl(K{K)J@ptv3bvX%9Hp2L zXN&uz2g2-$upVSOwcv1fz#d+o5qDb=a3H5kf>*4GPg<)S;-eRM789St#{z)*pN{nz zKeFiy+kb)`j}wjDc#ijugg)6)38M*p%a8Xuu z_)uhoVV+?P`@106vsBmF!S|~utsQj+K?7>g49QqjwPfPWrlBW3)D=vLAnJR{G>VA@ z$m7_sH4qyXc|zIF^ynI94x&R0B479uME3a;L?(6wg2?Uvh&EpgenafOd2XLHc{vma zL{~kCp(jj0KSdY_gZ`p?ajW!8q~w8Y$JuLvmIPguY?{G2wvq=TAN^4~B(=|;RJ?g6 zljp6F>nF1QQVg!3`q~+#&CGl;+Bc{LROI`977`HDLRaLQF$Vz+&3Kp&#&82LYh|Lj9F_sjZa6&#cWDteyw8%dj^>es==yXjzJEGbH3YKT81EgoAg z#xcM`EBz5uN395WM?kWiVNTBaW2wA=ysn3+nIO6^_( z>2iQG2MxN)K`E~yNPX}V^y(e<=qG5=sr|2?pdBe2rQmO*?Ll@s;NR%sJ$Az57l(1? z`X7F1mh%F;-p~D0)cK9V&6t2H2}xzY#*{})rB=mcG=M;RBlce+C88CcF=WQiPvKg+ zpoN8{bxV2bqGhR;PRef!j@I=^l=rGGL2k@+?(+u+FwO#ye%PbEDp|1TmaRL0zX&k` zGK#n99E?8(lL0LQ#n1cy5I!jV0|^3Dlpi{0na&0?h8P4L>XE4IZOi@_q`Wuq&kjo4 z^X`ySFQQucJO?cxta9)unM~2+e=0KRr(W2zv6b z9}w7gOiT6_pZwZ!pM9dRgK5g%DwgkI`A!sw$UZP$IAxH^2MBE6z_*=f5l$HlfJ7xQ z029|2%0NE()lI*1d_XSZX@QHs(+-~`8vX;uoFq2>m9_Z4d;0vpw!f1i|L?ZPX^{Ut zpy5ps&l*=XRdM^OzUF+<8;twY@mR21ko9Kdm&AaPHzhik%{845`O!}b`UI)9+$CYR zD8_v=TZNbfT?|$>IYwo|BB^r-CC=)x4_p{jT!S;1CSIiYaw7bKWoDnYqIMO~w z7nr`==yG!H5$b2y4zImqbT4dfPcGUaAJ^*^`&j4c#%Bc z@OXt=eB*Tt)&b?0`(oY$#avg>SF9M_AL-nZq}s#M5wRoKdmH#QcOaFQx4t4?H90jz zz<1>3zV7hK+bdd#9xRtT?CFol(fx}#9p}>#20GMpXgQoCct+UM@zN>N{>qN8Rdqs8 zG&Gfmrd9NkkP{QY{yE4B*##}0;urGhJ}o*y?4=@cU0>4b=sceW37JVWtR|xgMMEp~ zdUkm_DVWvqk^9ukG6Hrapz(%(&Bbs@Qgk?b;MLUc2FCKkM4Q4sgu>~=<%Q{t%41(bzA}wb zB0^9aR7l(nP4^@bi9)O8T5qzX@@cfuV({Jec$)?ufp}qV8Cp5nLP$%#$q87}eCDsR7|c_%t)?3!7Y7{ri{dni^}y#ulc>5VqPjj;WnOVd&4QO> zKkdi<1ojh8?5k{%kkL9al-x7UH^vv{I)TE_wv3#6z6evc*Q-&O8&+)F!cq#=7G8f3 zA|ebQw%t#>%=3)KCuXA;U?Nz>Bum0vrMc542-gALL=M)HewFWXICD{4>U-*e*6ryZ z@Hb0QGX<9KOwAS?U~Rz@iIyGW(u(NO>!+5N?x?dhar28hiU?{vi#7$)EdjokKJ;Kn zgjk~oIbAP@Zsr;B>lw~KZr>nha9>*mO?E@({M4Or`YP7>G4a-#cw46Ydvr^L#$LV+ zf>sgt{GO#r%`Qdvqu5u2k4x6w-Bq&dJ3(MzYXvy;lROtpw>K;OEg95?F!K=_)TTRk zxvtvS!-_#j%d}tVl_jOxJ9g1>8(dOcF0E)(AHbACN%GNCvOz}@?;6MV_V8Ml#U>l} z%J%T(`OD-+RZasJRyD$U9&5s6FBaR&`1O3FghJ!f?5;o=>z6E#!?IEx)NiK_aiA7k z0E)Ulk5HD|EqlVXLU%diQzD08j12H*d)OW>E8BsO@8hN%zvH`^3-s8e<_GvF&Z_QBQK3f)33Y`rd-KMB+N?jNv!hzx9dN zlb2iGWtG1XSh$Y4eD$kF#QvSH*25!H#-9Rr{d}WuDU8&3alU5;vah&P4|tgmT~i1F zH37NB02)hxvguJJ@^ghR@E!KPhBRygHBCKQ>YT3 zT_N{#&PkkcFMB<`()fCEmsP9-@Eb>->8uv$dt*#|a4kSrOEbVpFBAg=WIfGA!+JS6 zxquyMB9T@_x;DfkjVWBDT^*_SzU83#34Ua%%SVuzO^4Xk?sQ5opX^axbsKum+QT zn=~1Pq>0%9v@dqNDBBm4_gwUSzv^puGx^pxIm@TP8vHWGu*6)e zUK4G4nlr=0h-%(*oET$lCeB8KrBpzhW`yW~BQjjA!>EBUrPJwbxj{g$ zy8C`mbr+c2MS-v+vC%9={(i*x&PhD8a3*{O1jIG}5N5-utO3ew;Y`@+?C=%9U3c+> zN=uA=9>aji9B?+>W}3lf>0MD>;8Kodns&%WN``J!68o}ZP9H!K2UFw1$CWHfgXl{> z70-+^rqSL8%1Bc!Av^q-ZfGu|$auKb&pj}2A*An(6TH~jCoa2#vPn76EZ__v7DL(7 zH2hF30|lFQUa9$_{V1?8{<(_ZSxOWBVqMX;YG`T$$I45m7l!6q;I3U`gY&9B{Zv>; zJr^$5jJd;*?AB7QIy1>u0qgJ`&$b=w_)CoUQlqYzrFQ)ezSH$GxU1nQ3-2v)d7lU=2&CTQ$Em%vcS`nK6psd;E zP(&?_f5qJSZ#Zf9^nelJYSOLi9~+$Df4!`R)y|u+Y13jz;v!)j%FZZ05Eb4&qREsB zZm3b|T*+Ua6-{ZQgd6WhtFca!vUZDw0lVpfURAbA_KQVuv;0M-S6;fWVOqqZbGzSX zLExRb8SJ|Tbcj=tY2>=5=2_aZ_RKaf(NJ;f!I@#rQp_a8U2`FqH;rizpHlO{-P}l# z=7OEUFt#T2&TZo%K0C$4a98FYC2&Zdg`5oi7DZNkE~}4^5G96_OAt=V(F#(!SXo{3 zb{YRyMUH8k1Y+Isxgmn$JH!roWo0#6k@nKA1oT#KkBmU_>Iqf)6>x9Qxtm(F``uroZaubEEj!nUALc`pKde z4lG5-wPP?)Ev*%Z{0VB{@&)#6eBmAo-VQeUC0`_SncI0$a^dSn=v*IaKc=iH;fO%BLwb7~tYI-H8-KT}KD&6IE*vj97?1J)WQwYcd(1n1{3D ztEz}Z49^PDgNwEyMvlBgns!kY94KUeA3E7hbKQu5N9}(~pQ|WnWya)C8ZIprCZnbI zxUR^Tj?-n(q9ihJugj3;T~%m7Xeo~2ftf&cX}nSVu1Ke$mQ1yH1rrFi$SA@to~wAq zpK3*KoUWgP%R3a9ED#^MZZV*=5pb(Duk!BghtkU_4G!YK{W`C5{T5Jg{XVe6$@sP| zn&_sFwIfm;ThmyV!N3$`!){aTeK*dfc7^*VXqJ4Q-6I{r)Q=>qHqPq~IXYllJZfc2 z?)HipvyQIBqA9W(B#WwVa-2IzMl#)w=r`<>mz|8jd*coYbomaRURuVh<@CREu=gg* zr5HtCY}CCQn$6Cbjf9|I_i_k}Cp35HT0cf2y;p{?;tVC#r|Qja3qH>b*`*%Dw`Esv zDz9)z3RXiy&@Z)P9j{7<4oZ9TPxixe*%lOzdMfd*3wA)!5RMB-DsS^4;{um{cALj; m18yA-K`_?_4ur{sD-ajV;L3oeO;%0hRN5PueWV@uIq@&#@Lkye From e6739f6371df375cc0cef645cce0e41f0239f97c Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:07:02 +0100 Subject: [PATCH 04/14] docs: update README for Rust State the Rust + pnpm build requirements instead of Go, and remove the gopher banner image (and its now-unused asset). --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index f18808c..6a5cccc 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ Fast, beautiful diffs on the Go. -gopher reviewing code diffs - ## Motivation `diffs` is a local-first CLI in a single binary. Inspired by [DiffsHub](https://diffshub.com) from [pierre.computer](https://pierre.computer/), it brings a calmer review experience to your working tree and GitHub pull request. @@ -107,7 +105,7 @@ Use `reply`, `resolve`, and `reopen` to update a thread. Pass `--dir /path/to/re ## Build from source -Requires Go 1.26+ and pnpm. +Requires Rust (current stable, 1.96+) and pnpm/Node. ```sh pnpm install From 35dd33265228f0ee098529bafd5aaf580d405074 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:08:07 +0100 Subject: [PATCH 05/14] ci: exercise the release build contract on PRs Add --locked to CI clippy and test so a stale Cargo.lock fails PR CI instead of only failing under the release build, and add a snapshot job that runs cargo build --release --locked plus the archive packaging steps on linux and windows, mirroring the release workflow so packaging regressions surface before a tag is pushed. --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3202847..e0e40a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --all-targets --all-features --locked -- -D warnings test: name: Test @@ -123,4 +123,56 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run tests - run: cargo test --all-targets + run: cargo test --all-targets --locked + + snapshot: + name: Release build (${{ matrix.os_label }}) + needs: web + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + os_label: linux + - runner: windows-latest + os_label: windows + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build release binary + run: cargo build --release --locked + + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + name="diffs_snapshot_${{ matrix.os_label }}_amd64" + mkdir -p "dist/${name}" + cp target/release/diffs "dist/${name}/" + cp LICENSE README.md "dist/${name}/" + tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $name = "diffs_snapshot_${{ matrix.os_label }}_amd64" + New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null + Copy-Item target/release/diffs.exe "dist/$name/" + Copy-Item LICENSE,README.md "dist/$name/" + Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" From a060440d509a478ee2cbf16bcc7a76edf615f15d Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:17:00 +0100 Subject: [PATCH 06/14] chore: prepare 0.4.0 release --- .github/workflows/release.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +++--- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff4c0c4..05c7b52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,7 +235,7 @@ jobs: end name "diffs" - desc "Fast, beautiful diffs on the Go" + desc "A tiny CLI for fast, beautiful local-first diffs in the browser" homepage "https://github.com/imfing/diffs-cli" binary "diffs" diff --git a/Cargo.lock b/Cargo.lock index 70a1577..4c7e250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ dependencies = [ [[package]] name = "diffs" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index b5e5116..7700b48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffs" -version = "0.3.0" +version = "0.4.0" edition = "2024" description = "Review local diffs and GitHub pull requests in a browser" license = "MIT" diff --git a/README.md b/README.md index 6a5cccc..8e1228e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![CI](https://github.com/imfing/diffs-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/imfing/diffs-cli/actions/workflows/ci.yml) [![Release](https://github.com/imfing/diffs-cli/actions/workflows/release.yml/badge.svg)](https://github.com/imfing/diffs-cli/actions/workflows/release.yml) -Fast, beautiful diffs on the Go. +A tiny CLI for fast, beautiful local-first diffs in the browser. + +diffs UI screenshot ## Motivation @@ -41,8 +43,6 @@ diffs pr 123 # PR in the current repo diffs pr org/repo/pull/123 # PR in any repo ``` -diffs UI screenshot - Review the current branch against a base locally: ```sh diff --git a/package.json b/package.json index 1b6ca3b..93367f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diffs-cli", - "version": "0.1.0", + "version": "0.4.0", "private": true, "type": "module", "packageManager": "pnpm@10.33.2", From ad3ae06375bca425dc0c0c96d9046ff82731e464 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:21:25 +0100 Subject: [PATCH 07/14] fix: normalize gitignore paths on Windows --- src/git.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/git.rs b/src/git.rs index 3db537e..ba14c4a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -316,11 +316,44 @@ pub fn default_branch(cwd: impl AsRef) -> Option { /// working tree, or any lookup error, are treated as not ignored. pub fn is_path_ignored(repo: &Repository, path: impl AsRef) -> bool { let path = path.as_ref(); - let relative = repo - .workdir() - .and_then(|workdir| path.strip_prefix(workdir).ok()) - .unwrap_or(path); - repo.is_path_ignored(relative).unwrap_or(false) + let Some(workdir) = repo.workdir() else { + return false; + }; + let Some(relative) = workdir_relative_path(workdir, path) else { + return false; + }; + repo.is_path_ignored(Path::new(&relative)).unwrap_or(false) +} + +fn workdir_relative_path(workdir: &Path, path: &Path) -> Option { + if let Ok(relative) = path.strip_prefix(workdir) { + return Some(git_path(relative)); + } + + if let Ok(workdir) = workdir.canonicalize() + && let Ok(relative) = path.strip_prefix(workdir) + { + return Some(git_path(relative)); + } + + strip_git_path_prefix(&git_path(path), &git_path(workdir)) +} + +fn git_path(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + path.strip_prefix("//?/").unwrap_or(&path).to_string() +} + +fn strip_git_path_prefix(path: &str, workdir: &str) -> Option { + let workdir = workdir.trim_end_matches('/'); + if path.eq_ignore_ascii_case(workdir) { + return Some(String::new()); + } + + let prefix = format!("{workdir}/"); + path.get(prefix.len()..) + .filter(|_| path[..prefix.len()].eq_ignore_ascii_case(&prefix)) + .map(str::to_string) } #[cfg(test)] From cbb69d79dc05105ee6584f6dbeb79e67c64a7685 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:36:06 +0100 Subject: [PATCH 08/14] ci: simplify workflow maintenance --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0e40a2..03311d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: pnpm --dir web build - name: Upload web assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: web-dist path: web/dist @@ -78,7 +78,7 @@ jobs: uses: actions/checkout@v6 - name: Download web assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: web-dist path: web/dist @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@v6 - name: Download web assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: web-dist path: web/dist @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@v6 - name: Download web assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: web-dist path: web/dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05c7b52..d575ae1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - "v*" permissions: - contents: write + contents: read env: CARGO_TERM_COLOR: always @@ -51,7 +51,7 @@ jobs: run: pnpm --dir web build - name: Upload web assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: web-dist path: web/dist @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@v6 - name: Download web assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: web-dist path: web/dist @@ -122,7 +122,7 @@ jobs: uses: actions/checkout@v6 - name: Download web assets - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: web-dist path: web/dist @@ -161,7 +161,7 @@ jobs: Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" - name: Upload archive - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: archive-${{ matrix.os_label }}-${{ matrix.arch_label }} path: | @@ -169,15 +169,18 @@ jobs: dist/*.zip if-no-files-found: error retention-days: 1 + compression-level: 0 release: name: Publish release needs: build runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download archives - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: archive-* path: dist @@ -207,14 +210,13 @@ jobs: steps: - name: Download archives - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: archive-* path: dist merge-multiple: true - name: Render cask - id: cask shell: bash run: | version="${GITHUB_REF_NAME#v}" From 7940a384c1463b0314811a555db19ec41f763fc3 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 17:43:01 +0100 Subject: [PATCH 09/14] ci: preserve Linux Homebrew cask URLs --- .github/workflows/release.yml | 41 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d575ae1..22c2e0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,20 +220,39 @@ jobs: shell: bash run: | version="${GITHUB_REF_NAME#v}" - sha_arm="$(shasum -a 256 "dist/diffs_${version}_darwin_arm64.tar.gz" | awk '{print $1}')" - sha_intel="$(shasum -a 256 "dist/diffs_${version}_darwin_amd64.tar.gz" | awk '{print $1}')" + sha_darwin_arm="$(shasum -a 256 "dist/diffs_${version}_darwin_arm64.tar.gz" | awk '{print $1}')" + sha_darwin_intel="$(shasum -a 256 "dist/diffs_${version}_darwin_amd64.tar.gz" | awk '{print $1}')" + sha_linux_arm="$(shasum -a 256 "dist/diffs_${version}_linux_arm64.tar.gz" | awk '{print $1}')" + sha_linux_intel="$(shasum -a 256 "dist/diffs_${version}_linux_amd64.tar.gz" | awk '{print $1}')" mkdir -p out/Casks cat > out/Casks/diffs.rb < Date: Sat, 6 Jun 2026 17:46:29 +0100 Subject: [PATCH 10/14] ci: use Rust target triples for archives --- .github/workflows/ci.yml | 10 ++++---- .github/workflows/release.yml | 46 +++++++++++++++-------------------- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03311d4..e2a8151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,16 +126,16 @@ jobs: run: cargo test --all-targets --locked snapshot: - name: Release build (${{ matrix.os_label }}) + name: Release build (${{ matrix.target }}) needs: web strategy: fail-fast: false matrix: include: - runner: ubuntu-latest - os_label: linux + target: x86_64-unknown-linux-gnu - runner: windows-latest - os_label: windows + target: x86_64-pc-windows-msvc runs-on: ${{ matrix.runner }} steps: @@ -161,7 +161,7 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - name="diffs_snapshot_${{ matrix.os_label }}_amd64" + name="diffs-${{ matrix.target }}" mkdir -p "dist/${name}" cp target/release/diffs "dist/${name}/" cp LICENSE README.md "dist/${name}/" @@ -171,7 +171,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $name = "diffs_snapshot_${{ matrix.os_label }}_amd64" + $name = "diffs-${{ matrix.target }}" New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null Copy-Item target/release/diffs.exe "dist/$name/" Copy-Item LICENSE,README.md "dist/$name/" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22c2e0e..97cd7af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,30 +91,24 @@ jobs: run: cargo test --all-targets --locked build: - name: Build ${{ matrix.os_label }}_${{ matrix.arch_label }} + name: Build ${{ matrix.target }} needs: check strategy: fail-fast: false matrix: include: - runner: ubuntu-latest - os_label: linux - arch_label: amd64 + target: x86_64-unknown-linux-gnu - runner: ubuntu-24.04-arm - os_label: linux - arch_label: arm64 + target: aarch64-unknown-linux-gnu - runner: macos-13 - os_label: darwin - arch_label: amd64 + target: x86_64-apple-darwin - runner: macos-14 - os_label: darwin - arch_label: arm64 + target: aarch64-apple-darwin - runner: windows-latest - os_label: windows - arch_label: amd64 + target: x86_64-pc-windows-msvc - runner: windows-11-arm - os_label: windows - arch_label: arm64 + target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} steps: @@ -133,7 +127,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@v2 with: - key: ${{ matrix.os_label }}-${{ matrix.arch_label }} + key: ${{ matrix.target }} - name: Build release binary run: cargo build --release --locked @@ -142,8 +136,7 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - version="${GITHUB_REF_NAME#v}" - name="diffs_${version}_${{ matrix.os_label }}_${{ matrix.arch_label }}" + name="diffs-${{ matrix.target }}" mkdir -p "dist/${name}" cp target/release/diffs "dist/${name}/" cp LICENSE README.md "dist/${name}/" @@ -153,8 +146,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $version = "${env:GITHUB_REF_NAME}".TrimStart("v") - $name = "diffs_${version}_${{ matrix.os_label }}_${{ matrix.arch_label }}" + $name = "diffs-${{ matrix.target }}" New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null Copy-Item target/release/diffs.exe "dist/$name/" Copy-Item LICENSE,README.md "dist/$name/" @@ -163,7 +155,7 @@ jobs: - name: Upload archive uses: actions/upload-artifact@v7 with: - name: archive-${{ matrix.os_label }}-${{ matrix.arch_label }} + name: archive-${{ matrix.target }} path: | dist/*.tar.gz dist/*.zip @@ -220,10 +212,10 @@ jobs: shell: bash run: | version="${GITHUB_REF_NAME#v}" - sha_darwin_arm="$(shasum -a 256 "dist/diffs_${version}_darwin_arm64.tar.gz" | awk '{print $1}')" - sha_darwin_intel="$(shasum -a 256 "dist/diffs_${version}_darwin_amd64.tar.gz" | awk '{print $1}')" - sha_linux_arm="$(shasum -a 256 "dist/diffs_${version}_linux_arm64.tar.gz" | awk '{print $1}')" - sha_linux_intel="$(shasum -a 256 "dist/diffs_${version}_linux_amd64.tar.gz" | awk '{print $1}')" + sha_darwin_arm="$(shasum -a 256 "dist/diffs-aarch64-apple-darwin.tar.gz" | awk '{print $1}')" + sha_darwin_intel="$(shasum -a 256 "dist/diffs-x86_64-apple-darwin.tar.gz" | awk '{print $1}')" + sha_linux_arm="$(shasum -a 256 "dist/diffs-aarch64-unknown-linux-gnu.tar.gz" | awk '{print $1}')" + sha_linux_intel="$(shasum -a 256 "dist/diffs-x86_64-unknown-linux-gnu.tar.gz" | awk '{print $1}')" mkdir -p out/Casks cat > out/Casks/diffs.rb < Date: Sat, 6 Jun 2026 17:56:08 +0100 Subject: [PATCH 11/14] refactor: tighten hot-path helpers --- src/cli.rs | 49 ++++++++++++++++++++++++++++++------------------- src/server.rs | 8 ++++---- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 05d4599..2adbb9f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -355,10 +355,8 @@ fn new_reload_logger(color: bool) -> server::OnChange { fn print_reload(files: &[git::ChangedFile], color: bool) { let c = palette(color); let (label, message) = reload_line(files, &c, color); - println!( - "{}", - log_line(&c, &label, &message, reload_label_color(&label, &c)) - ); + let label_color = reload_label_color(files.first().map(|f| f.action), &c); + println!("{}", log_line(&c, &label, &message, label_color)); } fn reload_line(files: &[git::ChangedFile], c: &Colors, color: bool) -> (String, String) { @@ -378,13 +376,13 @@ fn reload_line(files: &[git::ChangedFile], c: &Colors, color: bool) -> (String, } } -fn reload_label_color(label: &str, c: &Colors) -> &'static str { - match label { - "added" => c.green, - "modified" => c.yellow, - "deleted" => c.red, - "renamed" => c.magenta, - _ => c.green, +fn reload_label_color(action: Option, c: &Colors) -> &'static str { + match action { + Some(git::ChangeAction::Added) => c.green, + Some(git::ChangeAction::Modified) => c.yellow, + Some(git::ChangeAction::Deleted) => c.red, + Some(git::ChangeAction::Renamed) => c.magenta, + None => c.green, } } @@ -453,9 +451,10 @@ fn bind_with_fallback( } fn browser_url(addr: SocketAddr, target_path: &str) -> String { - let host = match addr.ip().to_string().as_str() { - "0.0.0.0" | "::" => DEFAULT_HOST.to_string(), - value => value.to_string(), + let host = if addr.ip().is_unspecified() { + DEFAULT_HOST.to_string() + } else { + addr.ip().to_string() }; format!("http://{host}:{}{}", addr.port(), target_path) } @@ -765,11 +764,23 @@ mod tests { #[test] fn reload_label_color_by_action() { let c = palette(true); - assert_eq!(reload_label_color("added", &c), c.green); - assert_eq!(reload_label_color("modified", &c), c.yellow); - assert_eq!(reload_label_color("deleted", &c), c.red); - assert_eq!(reload_label_color("renamed", &c), c.magenta); - assert_eq!(reload_label_color("change", &c), c.green); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Added), &c), + c.green + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Modified), &c), + c.yellow + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Deleted), &c), + c.red + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Renamed), &c), + c.magenta + ); + assert_eq!(reload_label_color(None, &c), c.green); } #[test] diff --git a/src/server.rs b/src/server.rs index bc6d955..d81d0a0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -561,10 +561,10 @@ fn valid(check: impl Fn(&str) -> bool, value: &str) -> String { } fn dirty_enabled(value: Option<&str>) -> bool { - matches!( - value.unwrap_or_default().trim().to_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) + let value = value.unwrap_or_default().trim(); + ["1", "true", "yes", "on"] + .iter() + .any(|candidate| value.eq_ignore_ascii_case(candidate)) } fn is_safe_ref_arg(ref_name: &str) -> bool { From 5eb7d88f883502680a9235474cad0fba81d5baea Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 18:01:27 +0100 Subject: [PATCH 12/14] chore: refresh Rust package metadata --- Cargo.toml | 2 +- src/cli.rs | 6 +++--- src/gh.rs | 6 +++--- src/git.rs | 10 ++++------ src/server.rs | 25 ++++++++++++------------- tests/diff_parity.rs | 4 ++-- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7700b48..77a688f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "diffs" version = "0.4.0" edition = "2024" description = "Review local diffs and GitHub pull requests in a browser" -license = "MIT" +license = "Apache-2.0" [dependencies] anyhow = "1" diff --git a/src/cli.rs b/src/cli.rs index 2adbb9f..b681b04 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,7 +14,7 @@ const DEFAULT_PORT: u16 = 3433; const RELOAD_DEBOUNCE: Duration = Duration::from_millis(500); /// Error that signals a non-zero exit without printing anything (help/diagnostics -/// were already written). Mirrors Go's `quietError`. +/// were already written). #[derive(Debug)] pub struct QuietExit; @@ -203,7 +203,7 @@ async fn run_server_target( server::serve_router(listener, running.router).await } -// --- Terminal output (ported from cmd/diffs/output.go) --- +// --- Terminal output --- struct Colors { reset: &'static str, @@ -386,7 +386,7 @@ fn reload_label_color(action: Option, c: &Colors) -> &'static } } -/// Builds the human label for the served target (ported from `targetLabel`). +/// Builds the human label for the served target. fn target_label(target_path: &str, cwd: &std::path::Path) -> String { if target_path == "/local" { let branch = git::branch(cwd); diff --git a/src/gh.rs b/src/gh.rs index c1742a7..8102579 100644 --- a/src/gh.rs +++ b/src/gh.rs @@ -334,11 +334,11 @@ fn comment_side(side: &str) -> &str { } } -// --- GitHub review-thread CRUD (ported from internal/server/github_comments.go) --- +// --- GitHub review-thread CRUD --- // GitHub's GraphQL API returns `null` for fields like `line`, `path`, and -// `endCursor` (e.g. outdated or file-level threads). Go's encoding/json maps -// null to the zero value silently; serde errors unless we coalesce it here. +// `endCursor` (e.g. outdated or file-level threads). serde errors unless we +// coalesce those nulls here. fn null_to_default<'de, D, T>(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, diff --git a/src/git.rs b/src/git.rs index ba14c4a..39c0bdc 100644 --- a/src/git.rs +++ b/src/git.rs @@ -41,9 +41,8 @@ impl ChangeAction { } } -/// Maps a git2 status to a `ChangeAction` using the same precedence as the Go -/// watcher's `gitStatusAction`: deletion, then rename/copy, then addition, -/// otherwise modification. +/// Maps a git2 status to a `ChangeAction`: deletion, then rename/copy, then +/// addition, otherwise modification. fn status_action(status: Status) -> ChangeAction { if status.intersects(Status::INDEX_DELETED | Status::WT_DELETED) { ChangeAction::Deleted @@ -282,9 +281,8 @@ pub fn changed_files(cwd: impl AsRef) -> Result> { Ok(files) } -/// Builds a map of repository-relative (forward-slash) path → change action, -/// mirroring Go's `gitStatus`. Used by the watcher to label changed files for -/// the reload logger. +/// Builds a map of repository-relative (forward-slash) path to change action. +/// Used by the watcher to label changed files for the reload logger. pub fn status_map(repo: &Repository) -> Result> { let statuses = repo.statuses(Some(&mut status_options()))?; let mut map = BTreeMap::new(); diff --git a/src/server.rs b/src/server.rs index d81d0a0..714431b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -176,9 +176,8 @@ pub async fn serve(addr: SocketAddr, cfg: ServerConfig) -> anyhow::Result<()> { serve_router(listener, running.router).await } -/// Header-read timeout for incoming connections, mirroring Go's -/// `http.Server.ReadHeaderTimeout`. Bounds the slow-loris window where a client -/// opens a connection but never finishes sending request headers. +/// Header-read timeout for incoming connections. Bounds the slow-loris window +/// where a client opens a connection but never finishes sending request headers. const READ_HEADER_TIMEOUT: Duration = Duration::from_secs(5); /// Serves `router` over `listener` with a per-connection header-read timeout. @@ -597,9 +596,9 @@ enum CommentScope { Invalid, } -/// Mirrors Go's `commentTarget`: empty org/repo/number means local comments; -/// otherwise the trio must pass the same validators as the PR routes, or the -/// request is rejected with 400 (`Invalid`). +/// Empty org/repo/number means local comments; otherwise the trio must pass the +/// same validators as the PR routes, or the request is rejected with 400 +/// (`Invalid`). fn comment_scope(target: &CommentTargetQuery) -> CommentScope { let org = target.org.as_deref().unwrap_or_default(); let repo = target.repo.as_deref().unwrap_or_default(); @@ -684,7 +683,7 @@ fn mime_for(path: &str) -> &'static str { const WATCH_DEBOUNCE: Duration = Duration::from_millis(150); // Git-internal files whose changes mean the repository state moved (branch -// switch, commit, stage), mirroring the Go watcher's isGitStateEvent set. +// switch, commit, stage). const GIT_STATE_ENTRIES: [&str; 8] = [ "HEAD", "index", @@ -714,7 +713,7 @@ fn start_watcher( // notify invokes the event handler on its own (non-tokio) thread, so the // handler only classifies paths and forwards a tick over a sync channel. A // dedicated debounce thread coalesces bursts and resolves the changed files - // 150ms after the last event, matching the Go watcher's trailing timer. + // 150ms after the last event. let (tx, rx) = mpsc::channel::(); let status_cwd = cwd.clone(); std::thread::spawn(move || { @@ -736,7 +735,7 @@ fn start_watcher( } // Resolve which of the changed paths git actually reports, then - // gate the broadcast exactly like Go's notifyChange. + // broadcast only when the effective repository state changed. let status = repo.as_ref().and_then(|repo| git::status_map(repo).ok()); let changed = match &status { Some(map) => changed_files_for_events(&pending, map), @@ -829,9 +828,9 @@ fn is_git_state_file(git_dir: &FsPath, path: &FsPath) -> bool { } } -/// Intersects the changed event paths with git's reported status (Go's -/// `changedFilesForEvents`): an event path matches directly, or matches every -/// status entry beneath it when the event was on a directory. +/// Intersects the changed event paths with git's reported status: an event path +/// matches directly, or matches every status entry beneath it when the event was +/// on a directory. fn changed_files_for_events( events: &BTreeSet, status: &BTreeMap, @@ -863,7 +862,7 @@ fn changed_files_for_events( } /// Fallback when `git status` is unavailable: report every event path as -/// modified (Go's `changedFilesFromEvents`). +/// modified. fn changed_files_from_events(events: &BTreeSet) -> Vec { events .iter() diff --git a/tests/diff_parity.rs b/tests/diff_parity.rs index 6af7ced..bd5b13c 100644 --- a/tests/diff_parity.rs +++ b/tests/diff_parity.rs @@ -2,8 +2,8 @@ //! //! The frontend's `@pierre/diffs` parser consumes git's exact unified-diff text, //! so the libgit2-backed `local_diff`/`branch_diff` must reproduce it. The oracle -//! here is **git itself** (not the Go binary) — git is the ground truth the parser -//! targets, and this keeps the harness valid after the Go sources are removed. +//! here is **git itself**. Git is the ground truth the parser targets, and this +//! keeps the harness valid without depending on another implementation. //! //! Comparison is per-file: libgit2 emits every file (including untracked) in one //! path-sorted pass, whereas git's `diff HEAD` + per-file `--no-index` untracked From c5b2ba241f95dfe8e5601d3b360fcf7b8069d968 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 18:13:40 +0100 Subject: [PATCH 13/14] ci: create releases with GitHub CLI --- .github/workflows/release.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97cd7af..685b4f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,13 +186,17 @@ jobs: cat checksums.txt - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - files: | - dist/*.tar.gz - dist/*.zip + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --generate-notes \ + dist/*.tar.gz \ + dist/*.zip \ dist/checksums.txt - generate_release_notes: true homebrew: name: Update Homebrew cask From 0338b6ff0bec724ed0835a6397ae8c8ec38dfe14 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 6 Jun 2026 18:19:31 +0100 Subject: [PATCH 14/14] ci: share release archive packaging --- .github/actions/package/action.yml | 30 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 23 ++++------------------- .github/workflows/release.yml | 23 ++++------------------- 3 files changed, 38 insertions(+), 38 deletions(-) create mode 100644 .github/actions/package/action.yml diff --git a/.github/actions/package/action.yml b/.github/actions/package/action.yml new file mode 100644 index 0000000..244c1f7 --- /dev/null +++ b/.github/actions/package/action.yml @@ -0,0 +1,30 @@ +name: Package archive +description: Package a release binary with project metadata. + +inputs: + target: + description: Rust target triple used in the archive name. + required: true + +runs: + using: composite + steps: + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + name="diffs-${{ inputs.target }}" + mkdir -p "dist/${name}" + cp target/release/diffs "dist/${name}/" + cp LICENSE README.md "dist/${name}/" + tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $name = "diffs-${{ inputs.target }}" + New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null + Copy-Item target/release/diffs.exe "dist/$name/" + Copy-Item LICENSE,README.md "dist/$name/" + Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2a8151..0dd8bfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,22 +157,7 @@ jobs: - name: Build release binary run: cargo build --release --locked - - name: Package (unix) - if: runner.os != 'Windows' - shell: bash - run: | - name="diffs-${{ matrix.target }}" - mkdir -p "dist/${name}" - cp target/release/diffs "dist/${name}/" - cp LICENSE README.md "dist/${name}/" - tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md - - - name: Package (windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $name = "diffs-${{ matrix.target }}" - New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null - Copy-Item target/release/diffs.exe "dist/$name/" - Copy-Item LICENSE,README.md "dist/$name/" - Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" + - name: Package archive + uses: ./.github/actions/package + with: + target: ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 685b4f8..aecd1cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,25 +132,10 @@ jobs: - name: Build release binary run: cargo build --release --locked - - name: Package (unix) - if: runner.os != 'Windows' - shell: bash - run: | - name="diffs-${{ matrix.target }}" - mkdir -p "dist/${name}" - cp target/release/diffs "dist/${name}/" - cp LICENSE README.md "dist/${name}/" - tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md - - - name: Package (windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $name = "diffs-${{ matrix.target }}" - New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null - Copy-Item target/release/diffs.exe "dist/$name/" - Copy-Item LICENSE,README.md "dist/$name/" - Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" + - name: Package archive + uses: ./.github/actions/package + with: + target: ${{ matrix.target }} - name: Upload archive uses: actions/upload-artifact@v7