kitgit

tirbofish/kitgit · commit

221d7032c55768479873bf0e9c62e994ce35a5e4

initial commit

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 11:38

view full diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f183972
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+/target
+**/*.rs.bk
+.env
+deploy/.env
+deploy/.env.prod
+deploy/.bootstrap-password
+data/
+*.pem
+.DS_Store
+.deploy-staging/
+kitgit-deploy.tar
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..a46632f
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,4496 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[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.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures 0.2.17",
+ "password-hash",
+]
+
+[[package]]
+name = "askama"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow",
+]
+
+[[package]]
+name = "askama_web"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1acadd534892f9ef8c3809b47997e3cd857fad735edceff77a88be1c8236920"
+dependencies = [
+ "askama",
+ "askama_web_derive",
+ "axum-core",
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "askama_web_derive"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34921de3d57974069bad483fdfe0ec65d88c4ff892edd1ab4d8b03be0dda1b9b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-compression"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[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",
+ "multer",
+ "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-extra"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "futures-util",
+ "headers",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "serde_core",
+ "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 2.0.119",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bcrypt-pbkdf"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
+dependencies = [
+ "blowfish",
+ "pbkdf2",
+ "sha2",
+]
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[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 = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+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 = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
+[[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 = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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 = "compression-codecs"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "log",
+ "publicsuffix",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[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 = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "typenum",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "delegate"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core 0.6.4",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "git2"
+version = "0.20.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "openssl-probe",
+ "openssl-sys",
+ "url",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "headers"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "headers-core",
+ "http",
+ "httpdate",
+ "mime",
+ "sha1",
+]
+
+[[package]]
+name = "headers-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
+dependencies = [
+ "http",
+]
+
+[[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 = "hex-literal"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+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.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+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 0.62.2",
+]
+
+[[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 = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[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 = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[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 = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
+[[package]]
+name = "internal-russh-forked-ssh-key"
+version = "0.6.9+upstream-0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5af01d366561582e9ea5f841837cc1d8e37e7142a32f33a43801e81863cba5"
+dependencies = [
+ "argon2",
+ "bcrypt-pbkdf",
+ "ecdsa",
+ "ed25519-dalek",
+ "hex",
+ "hmac",
+ "num-bigint-dig",
+ "p256",
+ "p384",
+ "p521",
+ "rand_core 0.6.4",
+ "rsa",
+ "sec1",
+ "sha1",
+ "sha2",
+ "signature",
+ "ssh-cipher",
+ "ssh-encoding",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[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.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kitgit"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "askama",
+ "askama_web",
+ "async-trait",
+ "axum",
+ "axum-extra",
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "clap",
+ "dashmap",
+ "data-encoding",
+ "futures",
+ "git2",
+ "hex",
+ "hmac",
+ "http-body-util",
+ "mime_guess",
+ "openidconnect",
+ "pulldown-cmark",
+ "qrcode",
+ "rand 0.8.7",
+ "reqwest",
+ "russh",
+ "serde",
+ "serde_json",
+ "sha1",
+ "sha2",
+ "sqlx",
+ "syntect",
+ "thiserror 2.0.19",
+ "time",
+ "tokio",
+ "tokio-util",
+ "toml",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "urlencoding",
+ "uuid",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libgit2-sys"
+version = "0.18.7+1.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89"
+dependencies = [
+ "cc",
+ "libc",
+ "libssh2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.0",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[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 = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "md5"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[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 = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "rand 0.8.7",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.7",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http",
+ "rand 0.8.7",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[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 = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "openidconnect"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
+dependencies = [
+ "base64 0.21.7",
+ "chrono",
+ "dyn-clone",
+ "ed25519-dalek",
+ "hmac",
+ "http",
+ "itertools",
+ "log",
+ "oauth2",
+ "p256",
+ "p384",
+ "rand 0.8.7",
+ "rsa",
+ "serde",
+ "serde-value",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_plain",
+ "serde_with",
+ "sha2",
+ "subtle",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p521"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "rand_core 0.6.4",
+ "sha2",
+]
+
+[[package]]
+name = "pageant"
+version = "0.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c6f0e349ea8dea1b50aa17c082777d30df133d89898c7568a615354772d3731"
+dependencies = [
+ "bytes",
+ "delegate",
+ "futures",
+ "log",
+ "rand 0.8.7",
+ "thiserror 1.0.69",
+ "tokio",
+ "windows",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[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 = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs5"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
+dependencies = [
+ "aes",
+ "cbc",
+ "der",
+ "pbkdf2",
+ "scrypt",
+ "sha2",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "pkcs5",
+ "rand_core 0.6.4",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
+[[package]]
+name = "publicsuffix"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
+dependencies = [
+ "idna",
+ "psl-types",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
+[[package]]
+name = "qrcode"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.19",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.19",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[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.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20 0.10.1",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "cookie",
+ "cookie_store",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sha2",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "russh"
+version = "0.50.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d8075561703e70dab7b095b2c13597cde37f5be94af0849fa4e51c315020d0"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "bitflags",
+ "block-padding",
+ "byteorder",
+ "bytes",
+ "cbc",
+ "chacha20 0.9.1",
+ "ctr",
+ "curve25519-dalek",
+ "data-encoding",
+ "delegate",
+ "der",
+ "digest",
+ "ecdsa",
+ "ed25519-dalek",
+ "elliptic-curve",
+ "enum_dispatch",
+ "flate2",
+ "futures",
+ "generic-array",
+ "getrandom 0.2.17",
+ "hex-literal",
+ "hmac",
+ "home",
+ "inout",
+ "internal-russh-forked-ssh-key",
+ "log",
+ "md5",
+ "num-bigint",
+ "once_cell",
+ "p256",
+ "p384",
+ "p521",
+ "pageant",
+ "pbkdf2",
+ "pkcs1",
+ "pkcs5",
+ "pkcs8",
+ "poly1305",
+ "rand 0.8.7",
+ "rand_core 0.6.4",
+ "rsa",
+ "russh-cryptovec",
+ "russh-util",
+ "sec1",
+ "sha1",
+ "sha2",
+ "signature",
+ "spki",
+ "ssh-encoding",
+ "subtle",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-stream",
+ "typenum",
+ "zeroize",
+]
+
+[[package]]
+name = "russh-cryptovec"
+version = "0.50.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fcb7c127135848b47715b5bcb13d8a27ccd86ce1de1c15eab5982df91fe279a"
+dependencies = [
+ "libc",
+ "log",
+ "ssh-encoding",
+ "winapi",
+]
+
+[[package]]
+name = "russh-util"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c698d702527b51a82e64de98d506e9c1e83a063035d41df4f2354499ec090b79"
+dependencies = [
+ "chrono",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher",
+]
+
+[[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 = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "scrypt"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
+dependencies = [
+ "pbkdf2",
+ "salsa20",
+ "sha2",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[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.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+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_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.2",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[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 = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap 2.14.0",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror 2.0.19",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "uuid",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn 2.0.119",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand 0.8.7",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror 2.0.19",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand 0.8.7",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror 2.0.19",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror 2.0.19",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "ssh-cipher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "cbc",
+ "chacha20 0.9.1",
+ "cipher",
+ "ctr",
+ "poly1305",
+ "ssh-encoding",
+ "subtle",
+]
+
+[[package]]
+name = "ssh-encoding"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
+dependencies = [
+ "base64ct",
+ "bytes",
+ "pem-rfc7468",
+ "sha2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "fancy-regex",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "thiserror 2.0.19",
+ "walkdir",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl 2.0.19",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "async-compression",
+ "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",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.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-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[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 = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[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 = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[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 = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 2.0.119",
+]
+
+[[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.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[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.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result 0.2.0",
+ "windows-targets 0.52.6",
+]
+
+[[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.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[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 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[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 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..8c37b19
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,55 @@
+[package]
+name = "kitgit"
+version = "0.1.0"
+edition = "2021"
+description = "self-hosted git. templates, not a product page."
+license = "MIT"
+
+[dependencies]
+anyhow = "1"
+askama = { version = "0.14", features = ["serde_json"] }
+askama_web = { version = "0.14", features = ["axum-0.8"] }
+async-trait = "0.1"
+axum = { version = "0.8", features = ["multipart", "macros"] }
+axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
+base64 = "0.22"
+bytes = "1"
+chrono = { version = "0.4", features = ["serde"] }
+clap = { version = "4", features = ["derive", "env"] }
+data-encoding = "2"
+futures = "0.3"
+git2 = "0.20"
+hex = "0.4"
+hmac = "0.12"
+qrcode = { version = "0.14", default-features = false, features = ["svg"] }
+sha1 = "0.10"
+http-body-util = "0.1"
+mime_guess = "2"
+openidconnect = "4"
+pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
+rand = "0.8"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "gzip"] }
+russh = "0.50"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
+sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "migrate"] }
+syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }
+thiserror = "2"
+time = "0.3"
+tokio = { version = "1", features = ["full"] }
+tokio-util = { version = "0.7", features = ["io"] }
+toml = "0.8"
+tower = "0.5"
+tower-http = { version = "0.6", features = ["fs", "trace", "limit"] }
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+url = "2"
+urlencoding = "2"
+uuid = { version = "1", features = ["serde", "v4"] }
+dashmap = "6"
+
+[profile.release]
+lto = true
+codegen-units = 1
+strip = true
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d3efc8c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+# syntax=docker/dockerfile:1
+
+FROM rust:1-bookworm AS builder
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    pkg-config libssl-dev cmake git \
+    && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY Cargo.toml Cargo.lock* ./
+COPY src ./src
+COPY templates ./templates
+COPY static ./static
+COPY migrations ./migrations
+RUN cargo build --release
+
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    ca-certificates git git-lfs \
+    && git lfs install --system \
+    && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=builder /app/target/release/kitgit /usr/local/bin/kitgit
+COPY static /app/static
+COPY migrations /app/migrations
+ENV KITGIT_STATIC_DIR=/app/static
+ENV KITGIT_DATA_DIR=/data
+EXPOSE 8080 2222
+VOLUME ["/data"]
+ENTRYPOINT ["kitgit"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..65abac4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,90 @@
+# kitgit
+
+Self-hosted git. Templates, not a product page.
+
+Authentication is **Authentik OIDC only** — there is no local/dev password bypass.
+
+## Docker Compose
+
+```bash
+cd deploy
+cp .env.example .env
+# edit secrets if you want
+docker compose up -d --build
+```
+
+| Service | URL |
+|---------|-----|
+| kitgit | http://localhost:8080 |
+| kitgit SSH | localhost:2222 |
+| Authentik | http://localhost:9000 |
+
+### Authentik bootstrap
+
+Compose sets:
+
+- user: `akadmin`
+- password: value of `AUTHENTIK_BOOTSTRAP_PASSWORD` (default `kitgit-admin-change-me`)
+
+A blueprint at `deploy/authentik/blueprints/kitgit-oidc.yaml` creates the **kitgit** OAuth2 application (client id `kitgit`, redirect `http://localhost:8080/auth/callback`).
+
+After Authentik is up, apply/confirm the blueprint if needed:
+
+```bash
+docker compose exec authentik-worker ak apply_blueprint /blueprints/custom/kitgit-oidc.yaml
+```
+
+Then open http://localhost:8080/auth/login → Authentik → kitgit.
+
+### Site admins
+
+- The **first** successful OIDC login becomes a site admin automatically.
+- Site admins open **/admin** and can grant/revoke admin for any user.
+
+## Environment
+
+| Variable | Purpose |
+|----------|---------|
+| `KITGIT_DATABASE_URL` | Postgres |
+| `KITGIT_DATA_DIR` | Repos, avatars, SSH host key, releases |
+| `KITGIT_HTTP_BIND` / `KITGIT_SSH_BIND` | Listen addresses |
+| `KITGIT_PUBLIC_URL` | Public base URL for clone links |
+| `KITGIT_SESSION_SECRET` | Session cookie secret |
+| `KITGIT_OIDC_ISSUER` | Public issuer (browser), e.g. `http://localhost:9000/application/o/kitgit/` |
+| `KITGIT_OIDC_DISCOVERY_URL` | Discovery URL (defaults to issuer) |
+| `KITGIT_OIDC_CLIENT_ID` / `SECRET` / `REDIRECT_URL` | OIDC client |
+
+## Build
+
+```bash
+cargo build --release
+```
+
+## Backup
+
+- Postgres: `pg_dump` the `kitgit` database.
+- Data dir: bare repos, avatars, releases, SSH host key.
+
+## DigitalOcean (production)
+
+Minimal single-Droplet deploy under the **personal** project:
+
+| Resource | Value |
+|----------|-------|
+| Droplet | `kitgit` · `s-2vcpu-4gb` · `syd1` · Docker 1-Click |
+| Sites | https://git.tirbo.fish · https://auth.tirbo.fish |
+| Git SSH | `git.tirbo.fish:2222` |
+| Cost | ~$24/mo (droplet only; no managed DB/LB/registry) |
+
+On the droplet, the stack lives in `/opt/kitgit` and is started with:
+
+```bash
+cd /opt/kitgit/deploy
+docker compose up -d --build
+```
+
+Prod files: `deploy/docker-compose.prod.yml`, `deploy/Caddyfile`, `deploy/.env.prod.example`, `deploy/authentik/blueprints-prod/`.
+
+## Brand
+
+See `brand/index.html`. Mark glyph is **狐**.
diff --git a/README2.md b/README2.md
new file mode 100644
index 0000000..52412a1
--- /dev/null
+++ b/README2.md
@@ -0,0 +1,15 @@
+# kitgit
+
+a minimalistic git frontend build in Rust and pure HTML templates, allowing for blazing fast (how cliche) loading times and high performance.  
+
+## deployment
+
+```bash
+cd deploy
+cp .env.example .env
+# edit secrets if you want
+docker compose up -d --build
+```
+
+find kitgit at [localhost:8080](http://localhost:8080).
+
diff --git a/brand/index.html b/brand/index.html
new file mode 100644
index 0000000..83ba72f
--- /dev/null
+++ b/brand/index.html
@@ -0,0 +1,349 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>kitgit — brand</title>
+    <meta
+      name="description"
+      content="kitgit — self-hosted git. ink on paper."
+    />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <!-- Web fallback when a Nerd Font is not installed locally -->
+    <link
+      href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"
+      rel="stylesheet"
+    />
+    <link rel="stylesheet" href="./theme.css" />
+    <style>
+      .hero {
+        padding: var(--kg-space-6) 0 var(--kg-space-5);
+        min-height: calc(100vh - 4.5rem);
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+      }
+
+      .hero p {
+        max-width: 28rem;
+        margin: var(--kg-space-4) 0 var(--kg-space-5);
+        color: var(--kg-muted);
+        font-size: 1rem;
+      }
+
+      .section {
+        padding: var(--kg-space-5) 0;
+      }
+
+      .section + .section {
+        border-top: 1px solid var(--kg-line);
+      }
+
+      .stack {
+        display: flex;
+        flex-direction: column;
+        gap: var(--kg-space-3);
+      }
+
+      .type-row {
+        display: grid;
+        gap: var(--kg-space-2);
+      }
+
+      .type-row small {
+        color: var(--kg-faint);
+        font-size: 0.6875rem;
+        letter-spacing: 0.1em;
+        text-transform: uppercase;
+      }
+
+      .toggle {
+        font: inherit;
+        font-size: 0.75rem;
+        letter-spacing: 0.08em;
+        text-transform: uppercase;
+        color: var(--kg-muted);
+        background: none;
+        border: 0;
+        cursor: pointer;
+        padding: 0;
+      }
+
+      .toggle:hover {
+        color: var(--kg-fg);
+      }
+
+      .sample-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        border: 1px solid var(--kg-line);
+      }
+
+      .sample-list li {
+        display: flex;
+        justify-content: space-between;
+        gap: var(--kg-space-3);
+        padding: var(--kg-space-2) var(--kg-space-3);
+        border-bottom: 1px solid var(--kg-line);
+        font-size: 0.875rem;
+      }
+
+      .sample-list li:last-child {
+        border-bottom: 0;
+      }
+
+      .sample-list a {
+        color: var(--kg-fg);
+        text-decoration: none;
+      }
+
+      .sample-list a:hover {
+        text-decoration: underline;
+        text-underline-offset: 0.2em;
+      }
+
+      .voice {
+        display: grid;
+        gap: var(--kg-space-4);
+      }
+
+      @media (min-width: 640px) {
+        .voice {
+          grid-template-columns: 1fr 1fr;
+        }
+      }
+
+      .voice dt {
+        font-size: 0.75rem;
+        letter-spacing: 0.12em;
+        text-transform: uppercase;
+        color: var(--kg-muted);
+        margin-bottom: var(--kg-space-1);
+      }
+
+      .voice dd {
+        margin: 0;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="kg-shell">
+      <header class="kg-bar">
+        <a class="kg-mark" href="#">
+          <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+          <span class="kg-mark__name">kitgit</span>
+        </a>
+        <nav aria-label="Brand sections">
+          <a href="#mark">mark</a>
+          <a href="#type">type</a>
+          <a href="#ink">ink</a>
+          <a href="#voice">voice</a>
+        </nav>
+        <button
+          type="button"
+          class="toggle"
+          id="theme-toggle"
+          aria-pressed="false"
+        >
+          invert
+        </button>
+      </header>
+
+      <main>
+        <!-- first viewport: brand only -->
+        <section class="hero" aria-label="Brand">
+          <a class="kg-mark kg-mark--hero kg-caret" href="#">
+            <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+            <span class="kg-mark__name">kitgit</span>
+          </a>
+          <p>
+            Self-hosted git. Templates, not a product page. Ink on paper.
+          </p>
+          <div class="kg-actions">
+            <a class="kg-btn" href="#mark">read the system</a>
+            <a class="kg-btn kg-btn--ghost" href="#sample">see a template</a>
+          </div>
+        </section>
+
+        <section class="section" id="mark">
+          <span class="kg-kicker">01 — mark</span>
+          <h2 class="kg-display">Wordmark, not a logo lockup.</h2>
+          <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem">
+            Always lowercase. Always monospace. The mark is a solid ink square —
+            no SVG, no lockup. UI chrome elsewhere uses Nerd Font glyphs
+            (<code>nf-md-git</code>, branch, commit, etc.).
+          </p>
+          <div class="stack">
+            <a class="kg-mark kg-mark--hero" href="#">
+              <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+              <span class="kg-mark__name">kitgit</span>
+            </a>
+            <a class="kg-mark" href="#">
+              <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+              <span class="kg-mark__name">kitgit</span>
+            </a>
+            <span class="kg-mark kg-faint">
+              <span class="kg-mark__name">kitgit</span>
+            </span>
+          </div>
+        </section>
+
+        <section class="section" id="type">
+          <span class="kg-kicker">02 — type</span>
+          <h2 class="kg-display">One face. Nerd Font first.</h2>
+          <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem">
+            Primary stack:
+            <strong style="color: var(--kg-fg); font-weight: 500"
+              >JetBrainsMono Nerd Font</strong
+            >
+            → JetBrains Mono → FiraCode Nerd Font → system mono. No second
+            family. Hierarchy is weight and size only.
+          </p>
+          <div class="stack">
+            <div class="type-row">
+              <small>display / 700</small>
+              <p class="kg-display">clone. push. host.</p>
+            </div>
+            <div class="type-row">
+              <small>title / 700</small>
+              <p class="kg-title">thribhu / kitgit</p>
+            </div>
+            <div class="type-row">
+              <small>body / 400</small>
+              <p>
+                A bare forge for people who want git over http and ssh — and
+                almost nothing else.
+              </p>
+            </div>
+            <div class="type-row">
+              <small>muted / 400</small>
+              <p class="kg-muted">updated 2h ago · 3 commits · main</p>
+            </div>
+          </div>
+        </section>
+
+        <section class="section" id="ink">
+          <span class="kg-kicker">03 — ink</span>
+          <h2 class="kg-display">Black. White. Stop.</h2>
+          <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem">
+            No accent hue. Interaction is invert, underline, or opacity. Borders
+            are either faint paper lines or full ink.
+          </p>
+          <div class="kg-swatches" aria-label="Color tokens">
+            <div class="kg-swatch" style="background: #ffffff; color: #0a0a0a">
+              <span>bg #fff</span>
+            </div>
+            <div class="kg-swatch" style="background: #f7f7f7; color: #0a0a0a">
+              <span>raised #f7</span>
+            </div>
+            <div class="kg-swatch" style="background: #0a0a0a; color: #ffffff">
+              <span>fg #0a</span>
+            </div>
+            <div class="kg-swatch" style="background: #6b6b6b; color: #ffffff">
+              <span>muted #6b</span>
+            </div>
+            <div class="kg-swatch" style="background: #e5e5e5; color: #0a0a0a">
+              <span>line #e5</span>
+            </div>
+            <div class="kg-swatch" style="background: #0a0a0a; color: #ffffff">
+              <span>invert</span>
+            </div>
+          </div>
+          <pre
+            class="kg-code"
+            style="margin-top: 1.5rem"
+            aria-label="Install example"
+          ><span class="cm"># serve a bare repo</span>
+$ kitgit init ~/repos/app.git
+$ kitgit serve --listen 0.0.0.0:8080
+<span class="ok">listening on :8080</span></pre>
+        </section>
+
+        <section class="section" id="voice">
+          <span class="kg-kicker">04 — voice</span>
+          <h2 class="kg-display">Short. Literal. Quiet.</h2>
+          <dl class="voice" style="margin-top: 1.5rem">
+            <div>
+              <dt>Name</dt>
+              <dd><strong>kitgit</strong> — kit + git. A kit you host.</dd>
+            </div>
+            <div>
+              <dt>Tagline</dt>
+              <dd>Self-hosted git. Templates, not a product page.</dd>
+            </div>
+            <div>
+              <dt>Say</dt>
+              <dd>
+                “repos”, “bare”, “clone url”, “owner”, “push”. Prefer verbs over
+                marketing nouns.
+              </dd>
+            </div>
+            <div>
+              <dt>Don’t say</dt>
+              <dd>
+                “forge platform”, “developer experience”, “seamless”, “powerful”,
+                “next-gen”.
+              </dd>
+            </div>
+            <div>
+              <dt>UI copy</dt>
+              <dd>
+                Sentence case. No exclamation marks. Empty states are one line:
+                <em class="kg-muted">no repos yet.</em>
+              </dd>
+            </div>
+            <div>
+              <dt>Iconography</dt>
+              <dd>
+                Nerd Font codepoints only. No SVG icon packs. Prefer text labels
+                when a glyph would be decorative.
+              </dd>
+            </div>
+          </dl>
+        </section>
+
+        <section class="section" id="sample">
+          <span class="kg-kicker">05 — template sample</span>
+          <h2 class="kg-display">How a page should feel.</h2>
+          <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem">
+            One job. One title. Lists over cards. Metadata stays muted.
+          </p>
+          <p class="kg-title" style="margin-bottom: 0.75rem">repos</p>
+          <ul class="sample-list">
+            <li>
+              <a href="#">thribhu/kitgit</a>
+              <span class="kg-muted">main · 12c</span>
+            </li>
+            <li>
+              <a href="#">thribhu/notes</a>
+              <span class="kg-muted">trunk · 4c</span>
+            </li>
+            <li>
+              <a href="#">ops/backup</a>
+              <span class="kg-muted">master · 1c</span>
+            </li>
+          </ul>
+          <p class="kg-faint" style="margin-top: 1rem; font-size: 0.75rem">
+            clone · http://git.example/thribhu/kitgit.git
+          </p>
+        </section>
+      </main>
+
+      <footer class="kg-footer">
+        kitgit · ink on paper · brand v0.1
+      </footer>
+    </div>
+
+    <script>
+      const root = document.documentElement;
+      const btn = document.getElementById("theme-toggle");
+      btn.addEventListener("click", () => {
+        const on = root.classList.toggle("kg-dark");
+        btn.setAttribute("aria-pressed", String(on));
+        btn.textContent = on ? "paper" : "invert";
+      });
+    </script>
+  </body>
+</html>
diff --git a/brand/theme.css b/brand/theme.css
new file mode 100644
index 0000000..025ad40
--- /dev/null
+++ b/brand/theme.css
@@ -0,0 +1,371 @@
+/* kitgit — brand theme
+   ink on paper. no chrome. */
+
+:root {
+  /* surfaces */
+  --kg-bg: #ffffff;
+  --kg-bg-raised: #f7f7f7;
+  --kg-fg: #0a0a0a;
+  --kg-muted: #6b6b6b;
+  --kg-faint: #a3a3a3;
+  --kg-line: #e5e5e5;
+  --kg-line-strong: #0a0a0a;
+
+  /* interactive */
+  --kg-link: #0a0a0a;
+  --kg-link-hover: #0a0a0a;
+  --kg-focus: #0a0a0a;
+  --kg-danger: #0a0a0a;
+
+  /* invert block (nav, code, CTA) */
+  --kg-invert-bg: #0a0a0a;
+  --kg-invert-fg: #ffffff;
+  --kg-invert-muted: #a3a3a3;
+
+  /* type */
+  --kg-font: "JetBrainsMono Nerd Font", "JetBrainsMono NF", "JetBrains Mono",
+    "FiraCode Nerd Font", "Fira Code", "SauceCodePro Nerd Font",
+    ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
+  --kg-font-size: 14px;
+  --kg-line-height: 1.55;
+  --kg-tracking-tight: -0.02em;
+  --kg-tracking-brand: 0.08em;
+
+  /* rhythm */
+  --kg-space-1: 0.25rem;
+  --kg-space-2: 0.5rem;
+  --kg-space-3: 1rem;
+  --kg-space-4: 1.5rem;
+  --kg-space-5: 2.5rem;
+  --kg-space-6: 4rem;
+  --kg-max: 75rem;
+  --kg-radius: 0;
+
+  color-scheme: light;
+}
+
+/* optional dark invert — add class="kg-dark" on <html> */
+html.kg-dark {
+  --kg-bg: #0a0a0a;
+  --kg-bg-raised: #141414;
+  --kg-fg: #f5f5f5;
+  --kg-muted: #8a8a8a;
+  --kg-faint: #5c5c5c;
+  --kg-line: #262626;
+  --kg-line-strong: #f5f5f5;
+  --kg-link: #f5f5f5;
+  --kg-invert-bg: #f5f5f5;
+  --kg-invert-fg: #0a0a0a;
+  --kg-invert-muted: #525252;
+  color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  font-size: var(--kg-font-size);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-rendering: optimizeLegibility;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: var(--kg-font);
+  font-size: 1rem;
+  line-height: var(--kg-line-height);
+  color: var(--kg-fg);
+  background: var(--kg-bg);
+}
+
+/* —— wordmark —— */
+.kg-mark {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 0.55em;
+  font-family: var(--kg-font);
+  font-weight: 700;
+  font-size: 1.125rem;
+  letter-spacing: var(--kg-tracking-brand);
+  text-transform: lowercase;
+  text-decoration: none;
+  color: var(--kg-fg);
+  line-height: 1;
+}
+
+.kg-mark__glyph {
+  display: inline-block;
+  flex-shrink: 0;
+  align-self: center;
+  font-weight: 700;
+  letter-spacing: 0;
+  font-size: 1.15em;
+  line-height: 1;
+  speak: never;
+}
+
+.kg-mark__name {
+  letter-spacing: var(--kg-tracking-brand);
+}
+
+.kg-mark--hero {
+  font-size: clamp(2.5rem, 8vw, 4.5rem);
+  letter-spacing: 0.06em;
+}
+
+.kg-mark--hero .kg-mark__glyph {
+  font-size: 1.05em;
+}
+
+/* —— type scale —— */
+.kg-display {
+  font-family: var(--kg-font);
+  font-weight: 700;
+  font-size: clamp(1.75rem, 4vw, 2.25rem);
+  line-height: 1.2;
+  letter-spacing: var(--kg-tracking-tight);
+  margin: 0;
+}
+
+.kg-title {
+  font-weight: 700;
+  font-size: 1.125rem;
+  letter-spacing: var(--kg-tracking-tight);
+  margin: 0;
+}
+
+.kg-body {
+  margin: 0;
+  color: var(--kg-fg);
+}
+
+.kg-muted {
+  color: var(--kg-muted);
+}
+
+.kg-faint {
+  color: var(--kg-faint);
+}
+
+.kg-mono {
+  font-family: var(--kg-font);
+}
+
+.kg-kicker {
+  display: block;
+  font-size: 0.75rem;
+  letter-spacing: 0.14em;
+  text-transform: uppercase;
+  color: var(--kg-muted);
+  margin: 0 0 var(--kg-space-3);
+}
+
+/* —— links & controls —— */
+a.kg-link {
+  color: var(--kg-link);
+  text-decoration: underline;
+  text-underline-offset: 0.2em;
+  text-decoration-thickness: 1px;
+}
+
+a.kg-link:hover {
+  text-decoration-thickness: 2px;
+}
+
+.kg-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.5em;
+  padding: 0.65em 1.1em;
+  font-family: var(--kg-font);
+  font-size: 0.875rem;
+  font-weight: 500;
+  letter-spacing: 0.02em;
+  line-height: 1;
+  border: 1px solid var(--kg-line-strong);
+  border-radius: var(--kg-radius);
+  background: var(--kg-invert-bg);
+  color: var(--kg-invert-fg);
+  text-decoration: none;
+  cursor: pointer;
+  transition: opacity 120ms ease;
+}
+
+.kg-btn:hover {
+  opacity: 0.85;
+}
+
+.kg-btn:focus-visible {
+  outline: 2px solid var(--kg-focus);
+  outline-offset: 3px;
+}
+
+.kg-btn--ghost {
+  background: transparent;
+  color: var(--kg-fg);
+}
+
+.kg-btn--ghost:hover {
+  opacity: 1;
+  background: var(--kg-bg-raised);
+}
+
+.kg-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+}
+
+/* —— structure —— */
+.kg-shell {
+  width: min(100% - 2rem, var(--kg-max));
+  margin-inline: auto;
+}
+
+.kg-rule {
+  border: 0;
+  border-top: 1px solid var(--kg-line);
+  margin: var(--kg-space-5) 0;
+}
+
+.kg-rule--strong {
+  border-top-color: var(--kg-line-strong);
+}
+
+.kg-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  padding: var(--kg-space-3) 0;
+  border-bottom: 1px solid var(--kg-line);
+}
+
+.kg-bar nav {
+  display: flex;
+  gap: var(--kg-space-3);
+}
+
+.kg-bar nav a {
+  color: var(--kg-muted);
+  text-decoration: none;
+  font-size: 0.8125rem;
+  letter-spacing: 0.04em;
+}
+
+.kg-bar nav a:hover,
+.kg-bar nav a[aria-current="page"] {
+  color: var(--kg-fg);
+}
+
+.kg-footer {
+  padding: var(--kg-space-4) 0 var(--kg-space-6);
+  color: var(--kg-faint);
+  font-size: 0.75rem;
+  letter-spacing: 0.06em;
+}
+
+/* code / terminal strip */
+.kg-code {
+  display: block;
+  padding: var(--kg-space-3) var(--kg-space-4);
+  background: var(--kg-invert-bg);
+  color: var(--kg-invert-fg);
+  font-family: var(--kg-font);
+  font-size: 0.8125rem;
+  line-height: 1.6;
+  overflow-x: auto;
+  border-radius: var(--kg-radius);
+}
+
+.kg-code .cm {
+  color: var(--kg-invert-muted);
+}
+
+.kg-code .ok {
+  color: var(--kg-invert-fg);
+}
+
+/* token swatches (brand preview only) */
+.kg-swatches {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
+  gap: var(--kg-space-2);
+}
+
+.kg-swatch {
+  aspect-ratio: 1;
+  border: 1px solid var(--kg-line);
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+  padding: var(--kg-space-2);
+  font-size: 0.6875rem;
+  letter-spacing: 0.04em;
+}
+
+.kg-swatch span {
+  background: color-mix(in srgb, var(--kg-bg) 85%, transparent);
+  padding: 0.1em 0.25em;
+}
+
+/* motion — presence, not noise */
+@keyframes kg-ink {
+  from {
+    opacity: 0;
+    transform: translateY(0.4rem);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes kg-caret {
+  0%,
+  49% {
+    opacity: 1;
+  }
+  50%,
+  100% {
+    opacity: 0;
+  }
+}
+
+.kg-in {
+  animation: kg-ink 480ms ease both;
+}
+
+.kg-in-2 {
+  animation: kg-ink 480ms ease 80ms both;
+}
+
+.kg-in-3 {
+  animation: kg-ink 480ms ease 160ms both;
+}
+
+.kg-caret::after {
+  content: "█";
+  margin-left: 0.15em;
+  animation: kg-caret 1.1s step-end infinite;
+  font-weight: 400;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .kg-in,
+  .kg-in-2,
+  .kg-in-3 {
+    animation: none;
+  }
+
+  .kg-caret::after {
+    animation: none;
+    opacity: 1;
+  }
+}
diff --git a/deploy/.env.example b/deploy/.env.example
new file mode 100644
index 0000000..bd89f9b
--- /dev/null
+++ b/deploy/.env.example
@@ -0,0 +1,18 @@
+# Authentik
+AUTHENTIK_SECRET_KEY=changeme-authentik-secret-key-32chars
+AUTHENTIK_BOOTSTRAP_PASSWORD=kitgit-admin-change-me
+AUTHENTIK_BOOTSTRAP_EMAIL=admin@kitgit.local
+
+# kitgit
+KITGIT_PUBLIC_URL=http://localhost:8080
+KITGIT_SESSION_SECRET=changeme-kitgit-session-secret-32b
+KITGIT_OIDC_ISSUER=http://localhost:9000/application/o/kitgit/
+KITGIT_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/kitgit/
+KITGIT_OIDC_CLIENT_ID=kitgit
+KITGIT_OIDC_CLIENT_SECRET=changeme-oidc-client-secret
+KITGIT_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
+
+# Authentik API (signup). Must match blueprint token key.
+KITGIT_AUTHENTIK_URL=http://authentik-server:9000
+KITGIT_AUTHENTIK_API_TOKEN=changeme-kitgit-authentik-api-token
+AUTHENTIK_TOKEN=changeme-kitgit-authentik-api-token
diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example
new file mode 100644
index 0000000..6213ae9
--- /dev/null
+++ b/deploy/.env.prod.example
@@ -0,0 +1,18 @@
+# Copy to .env.prod and fill in secrets before deploy.
+POSTGRES_PASSWORD=
+AUTHENTIK_SECRET_KEY=
+AUTHENTIK_BOOTSTRAP_PASSWORD=
+AUTHENTIK_BOOTSTRAP_EMAIL=admin@tirbo.fish
+
+KITGIT_PUBLIC_URL=https://git.tirbo.fish
+KITGIT_SESSION_SECRET=
+KITGIT_OIDC_ISSUER=https://auth.tirbo.fish/application/o/kitgit/
+KITGIT_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/kitgit/
+KITGIT_OIDC_CLIENT_ID=kitgit
+KITGIT_OIDC_CLIENT_SECRET=
+KITGIT_OIDC_REDIRECT_URL=https://git.tirbo.fish/auth/callback
+
+KITGIT_AUTHENTIK_URL=http://authentik-server:9000
+KITGIT_AUTHENTIK_API_TOKEN=
+AUTHENTIK_TOKEN=
+KITGIT_AUTHENTIK_AUTH_FLOW=default-authentication-flow
diff --git a/deploy/Caddyfile b/deploy/Caddyfile
new file mode 100644
index 0000000..6935736
--- /dev/null
+++ b/deploy/Caddyfile
@@ -0,0 +1,9 @@
+git.tirbo.fish {
+	encode gzip
+	reverse_proxy kitgit:8080
+}
+
+auth.tirbo.fish {
+	encode gzip
+	reverse_proxy authentik-server:9000
+}
diff --git a/deploy/authentik/blueprints-prod/kitgit-oidc.yaml b/deploy/authentik/blueprints-prod/kitgit-oidc.yaml
new file mode 100644
index 0000000..d80906f
--- /dev/null
+++ b/deploy/authentik/blueprints-prod/kitgit-oidc.yaml
@@ -0,0 +1,70 @@
+# kitgit OIDC application for Authentik (production: tirbo.fish)
+# Replace PLACEHOLDER_* values to match deploy/.env.prod before first apply.
+version: 1
+metadata:
+  name: kitgit-oidc
+entries:
+  - model: authentik_providers_oauth2.oauth2provider
+    id: kitgit-provider
+    state: present
+    identifiers:
+      name: kitgit
+    attrs:
+      name: kitgit
+      client_type: confidential
+      client_id: kitgit
+      client_secret: PLACEHOLDER_OIDC_CLIENT_SECRET
+      redirect_uris:
+        - matching_mode: strict
+          url: https://git.tirbo.fish/auth/callback
+      include_claims_in_id_token: true
+      sub_mode: user_username
+      issuer_mode: per_provider
+      access_code_validity: minutes=10
+      access_token_validity: hours=1
+      refresh_token_validity: days=30
+      authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
+      authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
+      invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+      signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
+      property_mappings:
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
+
+  - model: authentik_core.application
+    id: kitgit-app
+    state: present
+    identifiers:
+      slug: kitgit
+    attrs:
+      name: kitgit
+      slug: kitgit
+      provider: !KeyOf kitgit-provider
+      policy_engine_mode: any
+      meta_launch_url: https://git.tirbo.fish/
+
+  - model: authentik_core.token
+    id: kitgit-api-token
+    state: present
+    identifiers:
+      identifier: kitgit-api
+    attrs:
+      identifier: kitgit-api
+      intent: api
+      expiring: false
+      description: kitgit service API token for user registration
+      user: !Find [authentik_core.user, [username, akadmin]]
+      key: PLACEHOLDER_AUTHENTIK_API_TOKEN
+
+  - model: authentik_stages_identification.identificationstage
+    id: default-authentication-identification
+    state: present
+    identifiers:
+      name: default-authentication-identification
+    attrs:
+      name: default-authentication-identification
+      user_fields:
+        - username
+        - email
+      captcha_stage: null
diff --git a/deploy/authentik/blueprints/kitgit-oidc.yaml b/deploy/authentik/blueprints/kitgit-oidc.yaml
new file mode 100644
index 0000000..967e8da
--- /dev/null
+++ b/deploy/authentik/blueprints/kitgit-oidc.yaml
@@ -0,0 +1,71 @@
+# kitgit OIDC application for Authentik
+version: 1
+metadata:
+  name: kitgit-oidc
+entries:
+  - model: authentik_providers_oauth2.oauth2provider
+    id: kitgit-provider
+    state: present
+    identifiers:
+      name: kitgit
+    attrs:
+      name: kitgit
+      client_type: confidential
+      client_id: kitgit
+      client_secret: changeme-oidc-client-secret
+      redirect_uris:
+        - matching_mode: strict
+          url: http://localhost:8080/auth/callback
+      include_claims_in_id_token: true
+      sub_mode: user_username
+      issuer_mode: per_provider
+      access_code_validity: minutes=10
+      access_token_validity: hours=1
+      refresh_token_validity: days=30
+      authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
+      authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
+      invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+      signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
+      property_mappings:
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
+        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
+
+  - model: authentik_core.application
+    id: kitgit-app
+    state: present
+    identifiers:
+      slug: kitgit
+    attrs:
+      name: kitgit
+      slug: kitgit
+      provider: !KeyOf kitgit-provider
+      policy_engine_mode: any
+      meta_launch_url: http://localhost:8080/
+
+  # API token for kitgit signup (create users via /api/v3/core/users/)
+  - model: authentik_core.token
+    id: kitgit-api-token
+    state: present
+    identifiers:
+      identifier: kitgit-api
+    attrs:
+      identifier: kitgit-api
+      intent: api
+      expiring: false
+      description: kitgit service API token for user registration
+      user: !Find [authentik_core.user, [username, akadmin]]
+      key: changeme-kitgit-authentik-api-token
+
+  # Ensure kitgit login works without captcha (kitgit UI has no captcha widget).
+  - model: authentik_stages_identification.identificationstage
+    id: default-authentication-identification
+    state: present
+    identifiers:
+      name: default-authentication-identification
+    attrs:
+      name: default-authentication-identification
+      user_fields:
+        - username
+        - email
+      captcha_stage: null
diff --git a/deploy/authentik/ensure-api-token.py b/deploy/authentik/ensure-api-token.py
new file mode 100644
index 0000000..aae83c8
--- /dev/null
+++ b/deploy/authentik/ensure-api-token.py
@@ -0,0 +1,26 @@
+#!/bin/sh
+# Ensure kitgit API token exists in Authentik (idempotent).
+# Usage: docker compose exec -T authentik-server ak shell < ensure-api-token.py
+# Or: python manage.py shell < this file via ak shell
+from authentik.core.models import Token, TokenIntents, User
+
+KEY = "changeme-kitgit-authentik-api-token"
+IDENT = "kitgit-api"
+
+user = User.objects.filter(username="akadmin").first()
+if user is None:
+    raise SystemExit("akadmin user not found yet")
+
+tok = Token.objects.filter(identifier=IDENT).first()
+if tok is None:
+    tok = Token(identifier=IDENT, user=user, intent=TokenIntents.INTENT_API, expiring=False)
+    tok.key = KEY
+    tok.save()
+    print(f"created token {IDENT}")
+else:
+    tok.key = KEY
+    tok.expiring = False
+    tok.user = user
+    tok.intent = TokenIntents.INTENT_API
+    tok.save()
+    print(f"updated token {IDENT}")
diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml
new file mode 100644
index 0000000..154b8d5
--- /dev/null
+++ b/deploy/docker-compose.prod.yml
@@ -0,0 +1,131 @@
+services:
+  caddy:
+    image: caddy:2-alpine
+    restart: unless-stopped
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile:ro
+      - caddy-data:/data
+      - caddy-config:/config
+    depends_on:
+      - kitgit
+      - authentik-server
+
+  postgres:
+    image: postgres:16-alpine
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: kitgit
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: postgres
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U kitgit"]
+      interval: 5s
+      timeout: 5s
+      retries: 10
+    # not published — internal only
+
+  redis:
+    image: redis:7-alpine
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 10
+
+  authentik-server:
+    image: ghcr.io/goauthentik/server:2024.10.4
+    command: server
+    restart: unless-stopped
+    environment:
+      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
+      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
+      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
+      AUTHENTIK_REDIS__HOST: redis
+      AUTHENTIK_POSTGRESQL__HOST: postgres
+      AUTHENTIK_POSTGRESQL__USER: kitgit
+      AUTHENTIK_POSTGRESQL__NAME: authentik
+      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
+      AUTHENTIK_HOST: https://auth.tirbo.fish
+      AUTHENTIK_HOST_BROWSER: https://auth.tirbo.fish
+    volumes:
+      - authentik-media:/media
+      - authentik-templates:/templates
+      - ./authentik/blueprints-prod:/blueprints/custom:ro
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+
+  authentik-worker:
+    image: ghcr.io/goauthentik/server:2024.10.4
+    command: worker
+    restart: unless-stopped
+    environment:
+      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
+      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
+      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
+      AUTHENTIK_REDIS__HOST: redis
+      AUTHENTIK_POSTGRESQL__HOST: postgres
+      AUTHENTIK_POSTGRESQL__USER: kitgit
+      AUTHENTIK_POSTGRESQL__NAME: authentik
+      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
+      AUTHENTIK_HOST: https://auth.tirbo.fish
+      AUTHENTIK_HOST_BROWSER: https://auth.tirbo.fish
+    user: root
+    volumes:
+      - authentik-media:/media
+      - authentik-templates:/templates
+      - ./authentik/blueprints-prod:/blueprints/custom:ro
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+
+  kitgit:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    environment:
+      KITGIT_DATABASE_URL: postgres://kitgit:${POSTGRES_PASSWORD}@postgres:5432/kitgit
+      KITGIT_DATA_DIR: /data
+      KITGIT_HTTP_BIND: 0.0.0.0:8080
+      KITGIT_SSH_BIND: 0.0.0.0:2222
+      KITGIT_PUBLIC_URL: ${KITGIT_PUBLIC_URL}
+      KITGIT_SESSION_SECRET: ${KITGIT_SESSION_SECRET}
+      KITGIT_OIDC_ISSUER: ${KITGIT_OIDC_ISSUER}
+      KITGIT_OIDC_DISCOVERY_URL: ${KITGIT_OIDC_DISCOVERY_URL}
+      KITGIT_OIDC_CLIENT_ID: ${KITGIT_OIDC_CLIENT_ID}
+      KITGIT_OIDC_CLIENT_SECRET: ${KITGIT_OIDC_CLIENT_SECRET}
+      KITGIT_OIDC_REDIRECT_URL: ${KITGIT_OIDC_REDIRECT_URL}
+      KITGIT_AUTHENTIK_URL: ${KITGIT_AUTHENTIK_URL}
+      KITGIT_AUTHENTIK_API_TOKEN: ${KITGIT_AUTHENTIK_API_TOKEN}
+      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN}
+      KITGIT_AUTHENTIK_AUTH_FLOW: ${KITGIT_AUTHENTIK_AUTH_FLOW:-default-authentication-flow}
+      RUST_LOG: info,kitgit=info
+    volumes:
+      - kitgit-data:/data
+    ports:
+      - "2222:2222"
+    depends_on:
+      postgres:
+        condition: service_healthy
+      authentik-server:
+        condition: service_started
+
+volumes:
+  pgdata:
+  authentik-media:
+  authentik-templates:
+  kitgit-data:
+  caddy-data:
+  caddy-config:
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..037ca86
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,112 @@
+services:
+  postgres:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_USER: kitgit
+      POSTGRES_PASSWORD: kitgit
+      POSTGRES_DB: postgres
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
+    ports:
+      - "5432:5432"
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U kitgit"]
+      interval: 5s
+      timeout: 5s
+      retries: 10
+
+  redis:
+    image: redis:7-alpine
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 10
+
+  authentik-server:
+    image: ghcr.io/goauthentik/server:2024.10.4
+    command: server
+    environment:
+      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-changeme-authentik-secret-key-32chars}
+      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-kitgit-admin-change-me}
+      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@kitgit.local}
+      AUTHENTIK_REDIS__HOST: redis
+      AUTHENTIK_POSTGRESQL__HOST: postgres
+      AUTHENTIK_POSTGRESQL__USER: kitgit
+      AUTHENTIK_POSTGRESQL__NAME: authentik
+      AUTHENTIK_POSTGRESQL__PASSWORD: kitgit
+    volumes:
+      - authentik-media:/media
+      - authentik-templates:/templates
+      - ./authentik/blueprints:/blueprints/custom:ro
+    ports:
+      - "9000:9000"
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+
+  authentik-worker:
+    image: ghcr.io/goauthentik/server:2024.10.4
+    command: worker
+    environment:
+      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-changeme-authentik-secret-key-32chars}
+      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-kitgit-admin-change-me}
+      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@kitgit.local}
+      AUTHENTIK_REDIS__HOST: redis
+      AUTHENTIK_POSTGRESQL__HOST: postgres
+      AUTHENTIK_POSTGRESQL__USER: kitgit
+      AUTHENTIK_POSTGRESQL__NAME: authentik
+      AUTHENTIK_POSTGRESQL__PASSWORD: kitgit
+    user: root
+    volumes:
+      - authentik-media:/media
+      - authentik-templates:/templates
+      - ./authentik/blueprints:/blueprints/custom:ro
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+
+  kitgit:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      KITGIT_DATABASE_URL: postgres://kitgit:kitgit@postgres:5432/kitgit
+      KITGIT_DATA_DIR: /data
+      KITGIT_HTTP_BIND: 0.0.0.0:8080
+      KITGIT_SSH_BIND: 0.0.0.0:2222
+      KITGIT_PUBLIC_URL: ${KITGIT_PUBLIC_URL:-http://localhost:8080}
+      KITGIT_SESSION_SECRET: ${KITGIT_SESSION_SECRET:-changeme-kitgit-session-secret-32b}
+      # Public issuer / browser-facing authorize host
+      KITGIT_OIDC_ISSUER: ${KITGIT_OIDC_ISSUER:-http://localhost:9000/application/o/kitgit/}
+      # Internal discovery (Docker DNS) — authorize URL rewritten to KITGIT_OIDC_ISSUER host
+      KITGIT_OIDC_DISCOVERY_URL: ${KITGIT_OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/kitgit/}
+      KITGIT_OIDC_CLIENT_ID: ${KITGIT_OIDC_CLIENT_ID:-kitgit}
+      KITGIT_OIDC_CLIENT_SECRET: ${KITGIT_OIDC_CLIENT_SECRET:-changeme-oidc-client-secret}
+      KITGIT_OIDC_REDIRECT_URL: ${KITGIT_OIDC_REDIRECT_URL:-http://localhost:8080/auth/callback}
+      KITGIT_AUTHENTIK_URL: ${KITGIT_AUTHENTIK_URL:-http://authentik-server:9000}
+      KITGIT_AUTHENTIK_API_TOKEN: ${KITGIT_AUTHENTIK_API_TOKEN:-changeme-kitgit-authentik-api-token}
+      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:-changeme-kitgit-authentik-api-token}
+      KITGIT_AUTHENTIK_AUTH_FLOW: ${KITGIT_AUTHENTIK_AUTH_FLOW:-default-authentication-flow}
+      RUST_LOG: info,kitgit=debug
+    volumes:
+      - kitgit-data:/data
+    ports:
+      - "8080:8080"
+      - "2222:2222"
+    depends_on:
+      postgres:
+        condition: service_healthy
+      authentik-server:
+        condition: service_started
+
+volumes:
+  pgdata:
+  authentik-media:
+  authentik-templates:
+  kitgit-data:
diff --git a/deploy/init-db.sql b/deploy/init-db.sql
new file mode 100644
index 0000000..085fb8f
--- /dev/null
+++ b/deploy/init-db.sql
@@ -0,0 +1,2 @@
+CREATE DATABASE kitgit;
+CREATE DATABASE authentik;
diff --git a/migrations/001_init.sql b/migrations/001_init.sql
new file mode 100644
index 0000000..ba271e9
--- /dev/null
+++ b/migrations/001_init.sql
@@ -0,0 +1,192 @@
+-- kitgit schema (phases 1–4)
+
+CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+
+CREATE TABLE users (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    oidc_sub TEXT NOT NULL UNIQUE,
+    username TEXT NOT NULL UNIQUE,
+    display_name TEXT NOT NULL DEFAULT '',
+    email TEXT NOT NULL DEFAULT '',
+    bio TEXT NOT NULL DEFAULT '',
+    avatar_path TEXT,
+    avatar_url TEXT,
+    is_site_admin BOOLEAN NOT NULL DEFAULT FALSE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    CONSTRAINT users_username_format CHECK (username ~ '^[a-z0-9][a-z0-9_-]{0,38}$')
+);
+
+CREATE TABLE sessions (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    token_hash TEXT NOT NULL UNIQUE,
+    expires_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX sessions_user_id_idx ON sessions(user_id);
+CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
+
+CREATE TABLE ssh_keys (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name TEXT NOT NULL DEFAULT 'key',
+    public_key TEXT NOT NULL,
+    fingerprint TEXT NOT NULL UNIQUE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX ssh_keys_user_id_idx ON ssh_keys(user_id);
+
+CREATE TABLE repositories (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name TEXT NOT NULL,
+    description TEXT NOT NULL DEFAULT '',
+    visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
+    default_branch TEXT NOT NULL DEFAULT 'main',
+    archived BOOLEAN NOT NULL DEFAULT FALSE,
+    -- units
+    issues_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+    pulls_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+    releases_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+    -- pull settings
+    allow_merge BOOLEAN NOT NULL DEFAULT TRUE,
+    allow_squash BOOLEAN NOT NULL DEFAULT TRUE,
+    allow_rebase BOOLEAN NOT NULL DEFAULT TRUE,
+    default_merge_style TEXT NOT NULL DEFAULT 'merge'
+        CHECK (default_merge_style IN ('merge', 'squash', 'rebase')),
+    -- branch protection (phase 4)
+    protect_default_branch BOOLEAN NOT NULL DEFAULT FALSE,
+    protect_block_force_push BOOLEAN NOT NULL DEFAULT TRUE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (owner_id, name),
+    CONSTRAINT repositories_name_format CHECK (name ~ '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$')
+);
+CREATE INDEX repositories_owner_id_idx ON repositories(owner_id);
+
+CREATE TABLE collaborators (
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    role TEXT NOT NULL CHECK (role IN ('read', 'write', 'admin')),
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (repo_id, user_id)
+);
+
+CREATE TABLE language_stats (
+    repo_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
+    stats JSONB NOT NULL DEFAULT '{}'::jsonb,
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE activity_events (
+    id BIGSERIAL PRIMARY KEY,
+    actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+    repo_id UUID REFERENCES repositories(id) ON DELETE CASCADE,
+    kind TEXT NOT NULL,
+    summary TEXT NOT NULL,
+    payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX activity_events_created_at_idx ON activity_events(created_at DESC);
+CREATE INDEX activity_events_actor_id_idx ON activity_events(actor_id);
+CREATE INDEX activity_events_repo_id_idx ON activity_events(repo_id);
+
+CREATE TABLE commit_activity (
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    day DATE NOT NULL,
+    count INT NOT NULL DEFAULT 0,
+    PRIMARY KEY (user_id, day)
+);
+
+CREATE TABLE issues (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    number INT NOT NULL,
+    author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    title TEXT NOT NULL,
+    body TEXT NOT NULL DEFAULT '',
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    closed_at TIMESTAMPTZ,
+    UNIQUE (repo_id, number)
+);
+CREATE INDEX issues_repo_id_idx ON issues(repo_id);
+
+CREATE TABLE pull_requests (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    number INT NOT NULL,
+    author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    title TEXT NOT NULL,
+    body TEXT NOT NULL DEFAULT '',
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'merged')),
+    source_branch TEXT NOT NULL,
+    target_branch TEXT NOT NULL,
+    merge_commit TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    merged_at TIMESTAMPTZ,
+    closed_at TIMESTAMPTZ,
+    UNIQUE (repo_id, number)
+);
+CREATE INDEX pull_requests_repo_id_idx ON pull_requests(repo_id);
+
+CREATE TABLE comments (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    issue_id UUID REFERENCES issues(id) ON DELETE CASCADE,
+    pull_id UUID REFERENCES pull_requests(id) ON DELETE CASCADE,
+    body TEXT NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    CONSTRAINT comments_target CHECK (
+        (issue_id IS NOT NULL AND pull_id IS NULL) OR
+        (issue_id IS NULL AND pull_id IS NOT NULL)
+    )
+);
+CREATE INDEX comments_issue_id_idx ON comments(issue_id);
+CREATE INDEX comments_pull_id_idx ON comments(pull_id);
+
+CREATE TABLE issue_labels (
+    issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+    label TEXT NOT NULL,
+    PRIMARY KEY (issue_id, label)
+);
+
+CREATE TABLE releases (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    tag_name TEXT NOT NULL,
+    title TEXT NOT NULL,
+    body TEXT NOT NULL DEFAULT '',
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (repo_id, tag_name)
+);
+
+CREATE TABLE release_assets (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
+    filename TEXT NOT NULL,
+    stored_path TEXT NOT NULL,
+    size_bytes BIGINT NOT NULL DEFAULT 0,
+    content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (release_id, filename)
+);
+
+CREATE TABLE repo_counters (
+    repo_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
+    next_issue INT NOT NULL DEFAULT 1,
+    next_pull INT NOT NULL DEFAULT 1
+);
+
+CREATE TABLE oidc_pending (
+    state TEXT PRIMARY KEY,
+    pkce_verifier TEXT NOT NULL,
+    nonce TEXT NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/migrations/002_site_admin.sql b/migrations/002_site_admin.sql
new file mode 100644
index 0000000..3d30e78
--- /dev/null
+++ b/migrations/002_site_admin.sql
@@ -0,0 +1,3 @@
+-- site admins (forge-wide, not repo ACL)
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_site_admin BOOLEAN NOT NULL DEFAULT FALSE;
+CREATE INDEX IF NOT EXISTS users_is_site_admin_idx ON users(is_site_admin) WHERE is_site_admin = TRUE;
diff --git a/migrations/003_site_settings.sql b/migrations/003_site_settings.sql
new file mode 100644
index 0000000..c894712
--- /dev/null
+++ b/migrations/003_site_settings.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS site_settings (
+    key TEXT PRIMARY KEY,
+    value TEXT NOT NULL DEFAULT ''
+);
+
+INSERT INTO site_settings (key, value) VALUES ('motd', '')
+ON CONFLICT (key) DO NOTHING;
diff --git a/migrations/004_social_privacy_lfs.sql b/migrations/004_social_privacy_lfs.sql
new file mode 100644
index 0000000..679e769
--- /dev/null
+++ b/migrations/004_social_privacy_lfs.sql
@@ -0,0 +1,91 @@
+-- Social, privacy, branch rules, LFS metadata
+
+ALTER TABLE users
+    ADD COLUMN IF NOT EXISTS show_email BOOLEAN NOT NULL DEFAULT FALSE,
+    ADD COLUMN IF NOT EXISTS vigilant_mode BOOLEAN NOT NULL DEFAULT FALSE;
+
+ALTER TABLE sessions
+    ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '',
+    ADD COLUMN IF NOT EXISTS ip_address TEXT NOT NULL DEFAULT '',
+    ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now();
+
+ALTER TABLE repositories
+    ADD COLUMN IF NOT EXISTS fork_of_id UUID REFERENCES repositories(id) ON DELETE SET NULL,
+    ADD COLUMN IF NOT EXISTS stars_count INT NOT NULL DEFAULT 0,
+    ADD COLUMN IF NOT EXISTS watches_count INT NOT NULL DEFAULT 0,
+    ADD COLUMN IF NOT EXISTS forks_count INT NOT NULL DEFAULT 0;
+
+CREATE INDEX IF NOT EXISTS repositories_fork_of_id_idx ON repositories(fork_of_id);
+
+CREATE TABLE IF NOT EXISTS user_emails (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    email TEXT NOT NULL,
+    verified BOOLEAN NOT NULL DEFAULT FALSE,
+    is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (user_id, email)
+);
+CREATE INDEX IF NOT EXISTS user_emails_user_id_idx ON user_emails(user_id);
+CREATE INDEX IF NOT EXISTS user_emails_email_idx ON user_emails(email);
+
+CREATE TABLE IF NOT EXISTS gpg_keys (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name TEXT NOT NULL DEFAULT 'gpg',
+    public_key TEXT NOT NULL,
+    fingerprint TEXT NOT NULL UNIQUE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS gpg_keys_user_id_idx ON gpg_keys(user_id);
+
+CREATE TABLE IF NOT EXISTS repo_stars (
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (repo_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS repo_watches (
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (repo_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS comment_reactions (
+    comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    emoji TEXT NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (comment_id, user_id, emoji),
+    CONSTRAINT comment_reactions_emoji CHECK (emoji IN ('+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', 'eyes'))
+);
+CREATE INDEX IF NOT EXISTS comment_reactions_comment_id_idx ON comment_reactions(comment_id);
+
+CREATE TABLE IF NOT EXISTS branch_rules (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    pattern TEXT NOT NULL,
+    require_pr BOOLEAN NOT NULL DEFAULT FALSE,
+    block_force_push BOOLEAN NOT NULL DEFAULT TRUE,
+    allow_deletions BOOLEAN NOT NULL DEFAULT FALSE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (repo_id, pattern)
+);
+CREATE INDEX IF NOT EXISTS branch_rules_repo_id_idx ON branch_rules(repo_id);
+
+CREATE TABLE IF NOT EXISTS lfs_objects (
+    oid TEXT PRIMARY KEY,
+    size_bytes BIGINT NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS lfs_repo_objects (
+    repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    oid TEXT NOT NULL REFERENCES lfs_objects(oid) ON DELETE CASCADE,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (repo_id, oid)
+);
+ALTER TABLE pull_requests
+    ADD COLUMN IF NOT EXISTS source_repo_id UUID REFERENCES repositories(id) ON DELETE SET NULL;
diff --git a/migrations/005_user_mfa.sql b/migrations/005_user_mfa.sql
new file mode 100644
index 0000000..6719395
--- /dev/null
+++ b/migrations/005_user_mfa.sql
@@ -0,0 +1,24 @@
+-- App-local TOTP MFA (kitgit UI; Authentik stays backend-only)
+
+CREATE TABLE user_mfa (
+    user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+    -- base32 TOTP secret; only set when enabled=true
+    totp_secret TEXT,
+    -- pending enrollment secret until confirmed with a valid code
+    pending_secret TEXT,
+    enabled BOOLEAN NOT NULL DEFAULT FALSE,
+    -- SHA-256 hex hashes of unused recovery codes
+    recovery_code_hashes TEXT[] NOT NULL DEFAULT '{}',
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE mfa_pending_logins (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    token_hash TEXT NOT NULL UNIQUE,
+    expires_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX mfa_pending_logins_expires_at_idx ON mfa_pending_logins(expires_at);
+CREATE INDEX mfa_pending_logins_user_id_idx ON mfa_pending_logins(user_id);
diff --git a/migrations/006_release_flags.sql b/migrations/006_release_flags.sql
new file mode 100644
index 0000000..cef2be9
--- /dev/null
+++ b/migrations/006_release_flags.sql
@@ -0,0 +1,6 @@
+-- Draft / prerelease flags and update timestamp for releases
+
+ALTER TABLE releases
+    ADD COLUMN IF NOT EXISTS is_prerelease BOOLEAN NOT NULL DEFAULT FALSE,
+    ADD COLUMN IF NOT EXISTS is_draft BOOLEAN NOT NULL DEFAULT FALSE,
+    ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..f34f61a
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,1075 @@
+use crate::config::Config;
+use crate::db::models::User;
+use crate::db::queries;
+use anyhow::{anyhow, Context, Result};
+use axum::http::HeaderMap;
+use axum::http::HeaderValue;
+use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
+use openidconnect::reqwest;
+use openidconnect::{
+    AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
+    EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
+    TokenResponse,
+};
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+use sqlx::PgPool;
+use std::sync::Arc;
+use url::Url;
+
+pub const SESSION_COOKIE: &str = "kitgit_session";
+pub const MFA_PENDING_COOKIE: &str = "kitgit_mfa_pending";
+
+/// Result of password login after Authentik accepts credentials.
+pub enum LoginOutcome {
+    /// Full kitgit session cookie value.
+    Complete { user: User, token: String },
+    /// Password ok; TOTP/recovery still required. Cookie value for pending MFA.
+    MfaRequired { pending_token: String },
+}
+
+type OidcClient = CoreClient<
+    EndpointSet,
+    EndpointNotSet,
+    EndpointNotSet,
+    EndpointNotSet,
+    EndpointMaybeSet,
+    EndpointMaybeSet,
+>;
+
+#[derive(Clone)]
+pub struct AuthState {
+    pub pool: PgPool,
+    pub config: Arc<Config>,
+    pub http: reqwest::Client,
+    pub oidc_enabled: bool,
+}
+
+impl AuthState {
+    pub async fn new(pool: PgPool, config: Arc<Config>) -> Result<Self> {
+        let http = reqwest::Client::builder()
+            .redirect(reqwest::redirect::Policy::none())
+            .timeout(std::time::Duration::from_secs(15))
+            .connect_timeout(std::time::Duration::from_secs(5))
+            .build()?;
+        let oidc_enabled = !config.oidc_issuer.is_empty() && !config.oidc_client_secret.is_empty();
+        let authentik_ready = !config.authentik_base().is_empty();
+        if !oidc_enabled && !authentik_ready {
+            tracing::error!("Auth not configured — set Authentik / OIDC env vars");
+        } else if oidc_enabled {
+            if let Err(e) = discover_client(&config, &http).await {
+                tracing::warn!("OIDC discovery failed at startup: {e:#}; will retry on demand");
+            }
+        }
+        Ok(Self {
+            pool,
+            config,
+            http,
+            oidc_enabled,
+        })
+    }
+}
+
+async fn discover_client(config: &Config, http: &reqwest::Client) -> Result<OidcClient> {
+    let discovery = config.discovery_issuer();
+    let issuer = IssuerUrl::new(discovery.to_string())?;
+    let meta = CoreProviderMetadata::discover_async(issuer, http)
+        .await
+        .context("OIDC discovery")?;
+    let client = CoreClient::from_provider_metadata(
+        meta,
+        ClientId::new(config.oidc_client_id.clone()),
+        Some(ClientSecret::new(config.oidc_client_secret.clone())),
+    )
+    .set_redirect_uri(RedirectUrl::new(config.oidc_redirect_url.clone())?);
+    Ok(client)
+}
+
+/// Rewrite URL host from internal discovery host to public issuer host (browser-facing).
+fn rewrite_public_url(raw: &str, config: &Config) -> Result<String> {
+    let public = Url::parse(&config.oidc_issuer).context("parse public issuer")?;
+    let discovery = Url::parse(config.discovery_issuer()).context("parse discovery issuer")?;
+    let mut u = Url::parse(raw).context("parse auth url")?;
+    if u.host_str() == discovery.host_str() && discovery.host_str() != public.host_str() {
+        let _ = u.set_scheme(public.scheme());
+        let _ = u.set_host(public.host_str());
+        let _ = u.set_port(public.port());
+    }
+    Ok(u.to_string())
+}
+
+pub fn hash_token(token: &str) -> String {
+    let mut h = Sha256::new();
+    h.update(token.as_bytes());
+    hex::encode(h.finalize())
+}
+
+pub fn new_session_token() -> String {
+    use rand::RngCore;
+    let mut buf = [0u8; 32];
+    rand::thread_rng().fill_bytes(&mut buf);
+    hex::encode(buf)
+}
+
+pub fn session_cookie_header(token: &str, max_age_secs: i64) -> HeaderValue {
+    let v = format!(
+        "{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}"
+    );
+    HeaderValue::from_str(&v).expect("cookie")
+}
+
+pub fn clear_session_cookie() -> HeaderValue {
+    HeaderValue::from_static("kitgit_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
+}
+
+pub fn mfa_pending_cookie_header(token: &str, max_age_secs: i64) -> HeaderValue {
+    let v = format!(
+        "{MFA_PENDING_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}"
+    );
+    HeaderValue::from_str(&v).expect("cookie")
+}
+
+pub fn clear_mfa_pending_cookie() -> HeaderValue {
+    HeaderValue::from_static("kitgit_mfa_pending=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
+}
+
+pub fn mfa_pending_from_headers(headers: &HeaderMap) -> Option<String> {
+    let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
+    for part in cookie.split(';') {
+        let part = part.trim();
+        if let Some(rest) = part.strip_prefix(&format!("{MFA_PENDING_COOKIE}=")) {
+            if !rest.is_empty() {
+                return Some(rest.to_string());
+            }
+        }
+    }
+    None
+}
+
+pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
+    let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
+    for part in cookie.split(';') {
+        let part = part.trim();
+        if let Some(rest) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
+            if !rest.is_empty() {
+                return Some(rest.to_string());
+            }
+        }
+    }
+    None
+}
+
+pub async fn current_user(auth: &AuthState, headers: &HeaderMap) -> Result<Option<User>> {
+    if let Some(token) = token_from_headers(headers) {
+        let hash = hash_token(&token);
+        if let Some(u) = queries::user_from_session(&auth.pool, &hash).await? {
+            return Ok(Some(u));
+        }
+    }
+    Ok(None)
+}
+
+pub async fn begin_login(auth: &AuthState) -> Result<(String, HeaderMap)> {
+    if !auth.oidc_enabled {
+        return Err(anyhow!("OIDC not configured"));
+    }
+    let client = discover_client(&auth.config, &auth.http).await?;
+    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
+    let (auth_url, csrf, nonce) = client
+        .authorize_url(
+            CoreAuthenticationFlow::AuthorizationCode,
+            CsrfToken::new_random,
+            Nonce::new_random,
+        )
+        .add_scope(Scope::new("openid".into()))
+        .add_scope(Scope::new("profile".into()))
+        .add_scope(Scope::new("email".into()))
+        .set_pkce_challenge(pkce_challenge)
+        .url();
+
+    queries::store_oidc_pending(
+        &auth.pool,
+        csrf.secret(),
+        pkce_verifier.secret(),
+        nonce.secret(),
+    )
+    .await?;
+
+    let public_url = rewrite_public_url(auth_url.as_str(), &auth.config)?;
+    Ok((public_url, HeaderMap::new()))
+}
+
+pub async fn finish_login(auth: &AuthState, code: &str, state: &str) -> Result<(User, String)> {
+    if !auth.oidc_enabled {
+        return Err(anyhow!("OIDC not configured"));
+    }
+    let client = discover_client(&auth.config, &auth.http).await?;
+    let (verifier, nonce) = queries::take_oidc_pending(&auth.pool, state)
+        .await?
+        .ok_or_else(|| anyhow!("unknown OIDC state"))?;
+
+    let token_response = client
+        .exchange_code(AuthorizationCode::new(code.to_string()))?
+        .set_pkce_verifier(PkceCodeVerifier::new(verifier))
+        .request_async(&auth.http)
+        .await
+        .context("token exchange")?;
+
+    let id_token = token_response
+        .id_token()
+        .ok_or_else(|| anyhow!("no id_token"))?;
+    let claims = id_token
+        .claims(&client.id_token_verifier(), &Nonce::new(nonce))
+        .context("verify id_token")?;
+
+    let sub = claims.subject().to_string();
+    let email = claims
+        .email()
+        .map(|e| e.to_string())
+        .unwrap_or_default();
+    let name = claims
+        .name()
+        .and_then(|n| n.get(None))
+        .map(|n| n.to_string())
+        .unwrap_or_else(|| email.clone());
+    let preferred = claims
+        .preferred_username()
+        .map(|s| s.to_string())
+        .filter(|s| !s.is_empty())
+        .or_else(|| email.split('@').next().map(|s| s.to_lowercase()))
+        .unwrap_or_else(|| format!("user{}", &sub[..8.min(sub.len())]));
+
+    let username = sanitize_username(&preferred);
+    let picture = claims
+        .picture()
+        .and_then(|p| p.get(None))
+        .map(|u| u.to_string());
+
+    let mut user = queries::upsert_user_from_oidc(
+        &auth.pool,
+        &sub,
+        &username,
+        &name,
+        &email,
+        picture.as_deref(),
+    )
+    .await?;
+
+    // First user to ever log in becomes site admin.
+    if queries::site_admin_count(&auth.pool).await? == 0 {
+        user = queries::set_site_admin(&auth.pool, user.id, true).await?;
+        tracing::info!("bootstrap site admin: {}", user.username);
+    }
+
+    let token = new_session_token();
+    queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?;
+    Ok((user, token))
+}
+
+pub async fn logout(auth: &AuthState, headers: &HeaderMap) -> Result<()> {
+    if let Some(token) = token_from_headers(headers) {
+        queries::delete_session(&auth.pool, &hash_token(&token)).await?;
+    }
+    Ok(())
+}
+
+fn sanitize_username(raw: &str) -> String {
+    let mut s: String = raw
+        .chars()
+        .map(|c| {
+            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+                c.to_ascii_lowercase()
+            } else {
+                '-'
+            }
+        })
+        .collect();
+    while s.starts_with('-') {
+        s.remove(0);
+    }
+    if s.is_empty() {
+        s = "user".into();
+    }
+    s.truncate(39);
+    s
+}
+
+// ── Authentik API (kitgit-hosted login & signup) ─────────────────────────────
+
+#[derive(Debug, Deserialize)]
+struct FlowChallenge {
+    component: String,
+    #[serde(default)]
+    to: Option<String>,
+    #[serde(default)]
+    response_errors: Option<serde_json::Value>,
+    #[serde(default)]
+    password_fields: Option<bool>,
+    /// Present on identification challenges (null when captcha disabled).
+    #[serde(default)]
+    captcha_stage: Option<serde_json::Value>,
+}
+
+#[derive(Debug, Deserialize)]
+struct AuthentikMe {
+    #[serde(default)]
+    pk: i64,
+    username: String,
+    #[serde(default)]
+    name: String,
+    #[serde(default)]
+    email: String,
+    #[serde(default)]
+    uid: String,
+}
+
+/// Authentik `/api/v3/core/users/me/` returns `{ "user": { ... } }` (SessionUserSerializer).
+#[derive(Debug, Deserialize)]
+struct AuthentikMeEnvelope {
+    user: AuthentikMe,
+}
+
+#[derive(Debug, Deserialize)]
+struct TokenResponseJson {
+    access_token: Option<String>,
+    id_token: Option<String>,
+    #[serde(default)]
+    error: Option<String>,
+    #[serde(default)]
+    error_description: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct UserinfoJson {
+    sub: Option<String>,
+    preferred_username: Option<String>,
+    name: Option<String>,
+    email: Option<String>,
+}
+
+fn new_http_client() -> Result<::reqwest::Client> {
+    Ok(::reqwest::Client::builder()
+        .cookie_store(true)
+        .redirect(::reqwest::redirect::Policy::none())
+        .timeout(std::time::Duration::from_secs(20))
+        .connect_timeout(std::time::Duration::from_secs(5))
+        .build()?)
+}
+
+fn truncate(s: &str, n: usize) -> String {
+    let t = s.trim();
+    if t.chars().count() <= n {
+        t.to_string()
+    } else {
+        format!("{}…", t.chars().take(n).collect::<String>())
+    }
+}
+
+/// Rewrite Authentik redirect targets onto the internal base (Docker DNS).
+fn rewrite_internal(internal_base: &str, location: &str) -> String {
+    let base = Url::parse(internal_base).ok();
+    if location.starts_with("http://") || location.starts_with("https://") {
+        if let (Ok(mut loc), Some(b)) = (Url::parse(location), base) {
+            let _ = loc.set_scheme(b.scheme());
+            let _ = loc.set_host(b.host_str());
+            let _ = loc.set_port(b.port());
+            return loc.to_string();
+        }
+        return location.to_string();
+    }
+    if let Some(b) = base {
+        if let Ok(joined) = b.join(location) {
+            return joined.to_string();
+        }
+    }
+    format!(
+        "{}{}",
+        internal_base.trim_end_matches('/'),
+        if location.starts_with('/') {
+            location.to_string()
+        } else {
+            format!("/{location}")
+        }
+    )
+}
+
+fn capture_csrf(resp: &::reqwest::Response, csrf: &mut Option<String>) {
+    for val in resp.headers().get_all(::reqwest::header::SET_COOKIE) {
+        let Ok(s) = val.to_str() else { continue };
+        let name_val = s.split(';').next().unwrap_or("").trim();
+        if let Some(v) = name_val
+            .strip_prefix("authentik_csrf=")
+            .or_else(|| name_val.strip_prefix("csrftoken="))
+        {
+            if !v.is_empty() {
+                *csrf = Some(v.to_string());
+            }
+        }
+    }
+}
+
+fn json_headers(csrf: &Option<String>, referer: Option<&str>) -> ::reqwest::header::HeaderMap {
+    let mut headers = ::reqwest::header::HeaderMap::new();
+    headers.insert(
+        ::reqwest::header::CONTENT_TYPE,
+        ::reqwest::header::HeaderValue::from_static("application/json"),
+    );
+    headers.insert(
+        ::reqwest::header::ACCEPT,
+        ::reqwest::header::HeaderValue::from_static("application/json"),
+    );
+    headers.insert(
+        ::reqwest::header::USER_AGENT,
+        ::reqwest::header::HeaderValue::from_static("kitgit/0.1"),
+    );
+    // Tip Authentik toward JSON challenge responses (not HTML/302 UI redirects).
+    headers.insert(
+        ::reqwest::header::HeaderName::from_static("x-requested-with"),
+        ::reqwest::header::HeaderValue::from_static("XMLHttpRequest"),
+    );
+    if let Some(r) = referer {
+        if let Ok(v) = ::reqwest::header::HeaderValue::from_str(r) {
+            headers.insert(::reqwest::header::REFERER, v);
+        }
+    }
+    if let Some(csrf) = csrf {
+        if let Ok(v) = ::reqwest::header::HeaderValue::from_str(csrf) {
+            headers.insert("X-authentik-CSRF", v.clone());
+            headers.insert("X-CSRFToken", v);
+        }
+    }
+    headers
+}
+
+fn bearer_headers(token: &str) -> ::reqwest::header::HeaderMap {
+    let mut headers = ::reqwest::header::HeaderMap::new();
+    headers.insert(
+        ::reqwest::header::ACCEPT,
+        ::reqwest::header::HeaderValue::from_static("application/json"),
+    );
+    headers.insert(
+        ::reqwest::header::CONTENT_TYPE,
+        ::reqwest::header::HeaderValue::from_static("application/json"),
+    );
+    if let Ok(v) = ::reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) {
+        headers.insert(::reqwest::header::AUTHORIZATION, v);
+    }
+    headers
+}
+
+async fn read_challenge(
+    resp: ::reqwest::Response,
+    csrf: &mut Option<String>,
+    internal_base: &str,
+    executor_url: &str,
+    client: &::reqwest::Client,
+    depth: u8,
+) -> Result<FlowChallenge> {
+    capture_csrf(&resp, csrf);
+    let status = resp.status();
+
+    // Authentik often answers stage POSTs with 302. Prefer staying on the JSON
+    // executor API — Location may point at the HTML /if/flow UI.
+    if status.is_redirection() {
+        let loc = resp
+            .headers()
+            .get(::reqwest::header::LOCATION)
+            .and_then(|v| v.to_str().ok())
+            .unwrap_or("")
+            .to_string();
+        tracing::debug!("authentik {status} Location={loc}");
+        if depth >= 10 {
+            anyhow::bail!("authentik redirect loop");
+        }
+        let next = if loc.contains("/api/v3/flows/executor/") {
+            rewrite_internal(internal_base, &loc)
+        } else {
+            // Browser UI or empty Location → re-GET the API executor with cookies.
+            executor_url.to_string()
+        };
+        let follow = client
+            .get(&next)
+            .headers(json_headers(csrf, Some(&next)))
+            .send()
+            .await
+            .context("authentik follow redirect")?;
+        return Box::pin(read_challenge(
+            follow,
+            csrf,
+            internal_base,
+            executor_url,
+            client,
+            depth + 1,
+        ))
+        .await;
+    }
+
+    let text = resp.text().await.unwrap_or_default();
+    if text.trim().is_empty() {
+        // Empty non-redirect — try executor GET once (session may already have advanced).
+        if depth < 10 {
+            tracing::debug!("authentik empty body ({status}); re-GET executor");
+            let follow = client
+                .get(executor_url)
+                .headers(json_headers(csrf, Some(executor_url)))
+                .send()
+                .await
+                .context("authentik empty-body recovery GET")?;
+            return Box::pin(read_challenge(
+                follow,
+                csrf,
+                internal_base,
+                executor_url,
+                client,
+                depth + 1,
+            ))
+            .await;
+        }
+        anyhow::bail!("authentik returned empty body ({status})");
+    }
+    if !status.is_success() && status.as_u16() != 400 {
+        tracing::warn!("authentik HTTP {status}: {}", truncate(&text, 400));
+        anyhow::bail!("authentik error ({status}): {}", truncate(&text, 200));
+    }
+    let challenge: FlowChallenge = serde_json::from_str(&text).map_err(|e| {
+        tracing::warn!(
+            "authentik JSON parse fail ({status}): {}",
+            truncate(&text, 400)
+        );
+        anyhow!("could not parse authentik response ({status}): {e}")
+    })?;
+    if challenge.component == "ak-stage-access-denied" {
+        anyhow::bail!("invalid username or password");
+    }
+    if let Some(errs) = challenge.response_errors.as_ref().and_then(|e| e.as_object()) {
+        if !errs.is_empty() {
+            tracing::warn!("authentik stage errors: {errs:?}");
+            // Surface captcha misconfig clearly; otherwise treat as bad credentials.
+            if errs.contains_key("captcha_stage") || errs.contains_key("captcha_token") {
+                anyhow::bail!("login misconfigured: captcha required by Authentik (disable captcha on identification stage)");
+            }
+            if errs.contains_key("uid_field")
+                || errs.values().any(|v| {
+                    v.to_string().to_lowercase().contains("invalid_identifier")
+                        || v.to_string().to_lowercase().contains("failed to authenticate")
+                })
+            {
+                anyhow::bail!("invalid username or password");
+            }
+            anyhow::bail!("invalid username or password");
+        }
+    }
+    Ok(challenge)
+}
+
+async fn flow_get(
+    client: &::reqwest::Client,
+    url: &str,
+    csrf: &mut Option<String>,
+    internal_base: &str,
+) -> Result<FlowChallenge> {
+    let resp = client
+        .get(url)
+        .headers(json_headers(csrf, Some(url)))
+        .send()
+        .await
+        .context("authentik flow GET")?;
+    read_challenge(resp, csrf, internal_base, url, client, 0).await
+}
+
+async fn flow_post(
+    client: &::reqwest::Client,
+    url: &str,
+    csrf: &mut Option<String>,
+    internal_base: &str,
+    body: serde_json::Value,
+) -> Result<FlowChallenge> {
+    tracing::debug!("authentik POST {url} body={}", truncate(&body.to_string(), 200));
+    let resp = client
+        .post(url)
+        .headers(json_headers(csrf, Some(url)))
+        .json(&body)
+        .send()
+        .await
+        .context("authentik flow POST")?;
+    read_challenge(resp, csrf, internal_base, url, client, 0).await
+}
+
+async fn run_password_flow(
+    client: &::reqwest::Client,
+    base: &str,
+    flow_slug: &str,
+    username: &str,
+    password: &str,
+) -> Result<()> {
+    let url = format!("{base}/api/v3/flows/executor/{flow_slug}/");
+    let mut csrf = None;
+    let mut challenge = flow_get(client, &url, &mut csrf, base).await?;
+    let mut password_sent = false;
+    let mut identification_sent = false;
+
+    for _ in 0..12 {
+        match challenge.component.as_str() {
+            "xak-flow-redirect" => {
+                // Login finished — session cookie is set. Optionally hit `to`.
+                if let Some(to) = challenge.to.as_deref() {
+                    let next = rewrite_internal(base, to);
+                    if next.contains("/api/") {
+                        let _ = client
+                            .get(&next)
+                            .headers(json_headers(&csrf, Some(&next)))
+                            .send()
+                            .await;
+                    }
+                }
+                return Ok(());
+            }
+            "ak-stage-access-denied" => anyhow::bail!("invalid username or password"),
+            "ak-stage-identification" => {
+                if identification_sent {
+                    anyhow::bail!("invalid username or password");
+                }
+                identification_sent = true;
+                if challenge.captcha_stage.as_ref().is_some_and(|v| !v.is_null()) {
+                    anyhow::bail!(
+                        "login misconfigured: Authentik identification stage has captcha enabled; disable it for kitgit"
+                    );
+                }
+                let with_password = challenge.password_fields == Some(true);
+                // captcha_stage/captcha_token must be present as empty strings (not JSON null)
+                // on Authentik builds that validate those keys.
+                challenge = flow_post(
+                    client,
+                    &url,
+                    &mut csrf,
+                    base,
+                    serde_json::json!({
+                        "component": "ak-stage-identification",
+                        "uid_field": username,
+                        "password": if with_password { password } else { "" },
+                        "captcha_token": "",
+                        "captcha_stage": "",
+                    }),
+                )
+                .await?;
+            }
+            "ak-stage-password" => {
+                if password_sent {
+                    anyhow::bail!("invalid username or password");
+                }
+                password_sent = true;
+                challenge = flow_post(
+                    client,
+                    &url,
+                    &mut csrf,
+                    base,
+                    serde_json::json!({
+                        "component": "ak-stage-password",
+                        "password": password,
+                    }),
+                )
+                .await?;
+            }
+            "ak-stage-user-login" => {
+                challenge = flow_post(
+                    client,
+                    &url,
+                    &mut csrf,
+                    base,
+                    serde_json::json!({ "component": "ak-stage-user-login" }),
+                )
+                .await?;
+            }
+            "ak-stage-captcha" => {
+                anyhow::bail!(
+                    "login misconfigured: Authentik captcha stage is enabled; disable captcha for kitgit"
+                );
+            }
+            "ak-stage-authenticator-validate" => {
+                // Never send the browser to Authentik MFA; kitgit uses app-local MFA.
+                anyhow::bail!("invalid username or password");
+            }
+            other => {
+                tracing::debug!("authentik stage {other}, acknowledging");
+                challenge = flow_post(
+                    client,
+                    &url,
+                    &mut csrf,
+                    base,
+                    serde_json::json!({ "component": other }),
+                )
+                .await
+                .with_context(|| format!("unexpected authentik stage: {other}"))?;
+            }
+        }
+    }
+    anyhow::bail!("authentik login flow did not complete")
+}
+
+async fn try_password_grant(
+    auth: &AuthState,
+    username: &str,
+    password: &str,
+) -> Result<AuthentikMe> {
+    let base = auth.config.authentik_base();
+    let client = new_http_client()?;
+    let token_url = format!("{base}/application/o/token/");
+    let resp = client
+        .post(&token_url)
+        .header(::reqwest::header::ACCEPT, "application/json")
+        .form(&[
+            ("grant_type", "password"),
+            ("username", username),
+            ("password", password),
+            ("client_id", auth.config.oidc_client_id.as_str()),
+            ("client_secret", auth.config.oidc_client_secret.as_str()),
+            ("scope", "openid profile email"),
+        ])
+        .send()
+        .await
+        .context("password grant")?;
+    let status = resp.status();
+    let text = resp.text().await.unwrap_or_default();
+    let parsed: TokenResponseJson = serde_json::from_str(&text).unwrap_or(TokenResponseJson {
+        access_token: None,
+        id_token: None,
+        error: Some(format!("http_{status}")),
+        error_description: Some(truncate(&text, 200)),
+    });
+    if let Some(err) = parsed.error {
+        anyhow::bail!(
+            "password grant unavailable ({err}): {}",
+            parsed.error_description.unwrap_or_default()
+        );
+    }
+    let access = parsed
+        .access_token
+        .ok_or_else(|| anyhow!("password grant: no access_token ({status}) {}", truncate(&text, 200)))?;
+
+    let ui = client
+        .get(format!("{base}/application/o/userinfo/"))
+        .header(::reqwest::header::AUTHORIZATION, format!("Bearer {access}"))
+        .header(::reqwest::header::ACCEPT, "application/json")
+        .send()
+        .await
+        .context("userinfo")?;
+    let ui_status = ui.status();
+    let ui_text = ui.text().await.unwrap_or_default();
+    if !ui_status.is_success() {
+        anyhow::bail!("userinfo {ui_status}: {}", truncate(&ui_text, 200));
+    }
+    let info: UserinfoJson = serde_json::from_str(&ui_text).context("parse userinfo")?;
+    let username = info
+        .preferred_username
+        .filter(|s| !s.is_empty())
+        .or_else(|| info.email.as_ref().and_then(|e| e.split('@').next().map(|s| s.to_string())))
+        .unwrap_or_else(|| username.to_string());
+    Ok(AuthentikMe {
+        pk: 0,
+        username,
+        name: info.name.unwrap_or_default(),
+        email: info.email.unwrap_or_default(),
+        uid: info.sub.unwrap_or_default(),
+    })
+}
+
+async fn authentik_me(client: &::reqwest::Client, base: &str) -> Result<AuthentikMe> {
+    let resp = client
+        .get(format!("{base}/api/v3/core/users/me/"))
+        .header(::reqwest::header::ACCEPT, "application/json")
+        .send()
+        .await
+        .context("authentik users/me")?;
+    let status = resp.status();
+    let text = resp.text().await.unwrap_or_default();
+    if !status.is_success() {
+        tracing::warn!("users/me {status}: {}", truncate(&text, 300));
+        anyhow::bail!("session not established ({status})");
+    }
+    // Nested `{ "user": {...} }` is the normal Authentik session shape; also accept flat.
+    if let Ok(env) = serde_json::from_str::<AuthentikMeEnvelope>(&text) {
+        return Ok(env.user);
+    }
+    serde_json::from_str(&text).with_context(|| {
+        format!("parse users/me: {}", truncate(&text, 200))
+    })
+}
+
+async fn user_from_authentik_me(auth: &AuthState, me: &AuthentikMe) -> Result<User> {
+    let sub = if !me.uid.is_empty() {
+        me.uid.clone()
+    } else if me.pk != 0 {
+        format!("ak:{}", me.pk)
+    } else {
+        format!("ak:{}", me.username)
+    };
+    let username = sanitize_username(&me.username);
+    let name = if me.name.is_empty() {
+        username.clone()
+    } else {
+        me.name.clone()
+    };
+    let mut user = queries::upsert_user_from_oidc(
+        &auth.pool,
+        &sub,
+        &username,
+        &name,
+        &me.email,
+        None,
+    )
+    .await?;
+    if queries::site_admin_count(&auth.pool).await? == 0 {
+        user = queries::set_site_admin(&auth.pool, user.id, true).await?;
+        tracing::info!("bootstrap site admin: {}", user.username);
+    }
+    Ok(user)
+}
+
+async fn create_kitgit_session(auth: &AuthState, user: &User) -> Result<(User, String)> {
+    let token = new_session_token();
+    queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?;
+    Ok((user.clone(), token))
+}
+
+/// Verify username/password against Authentik only (no kitgit session, no MFA gate).
+pub async fn verify_password(
+    auth: &AuthState,
+    username: &str,
+    password: &str,
+) -> Result<User> {
+    let base = auth.config.authentik_base();
+    if base.is_empty() {
+        return Err(anyhow!("identity provider not configured"));
+    }
+    let username = username.trim();
+    if username.is_empty() || password.is_empty() {
+        anyhow::bail!("username and password required");
+    }
+
+    match try_password_grant(auth, username, password).await {
+        Ok(me) => {
+            tracing::info!("password verified via grant: {}", me.username);
+            return user_from_authentik_me(auth, &me).await;
+        }
+        Err(e) => {
+            tracing::debug!("password grant unavailable, trying flow: {e:#}");
+        }
+    }
+
+    let client = new_http_client()?;
+    run_password_flow(
+        &client,
+        &base,
+        &auth.config.authentik_auth_flow,
+        username,
+        password,
+    )
+    .await
+    .map_err(|e| {
+        let msg = e.to_string();
+        tracing::warn!("authentik flow login failed: {e:#}");
+        if msg.contains("misconfigured") || msg.contains("captcha") {
+            e
+        } else {
+            anyhow!("invalid username or password")
+        }
+    })?;
+    let me = authentik_me(&client, &base)
+        .await
+        .context("password accepted but user could not be read")?;
+    user_from_authentik_me(auth, &me).await
+}
+
+/// Authenticate with Authentik, then enforce app-local MFA when enabled.
+pub async fn login_with_password(
+    auth: &AuthState,
+    username: &str,
+    password: &str,
+) -> Result<LoginOutcome> {
+    let user = verify_password(auth, username, password).await?;
+    if queries::mfa_is_enabled(&auth.pool, user.id).await? {
+        let pending = new_session_token();
+        queries::delete_mfa_pending_for_user(&auth.pool, user.id).await?;
+        queries::create_mfa_pending_login(&auth.pool, user.id, &hash_token(&pending), 10).await?;
+        return Ok(LoginOutcome::MfaRequired {
+            pending_token: pending,
+        });
+    }
+    let (user, token) = create_kitgit_session(auth, &user).await?;
+    Ok(LoginOutcome::Complete { user, token })
+}
+
+/// Complete login after TOTP / recovery code for a pending MFA cookie.
+pub async fn complete_mfa_login(
+    auth: &AuthState,
+    pending_token: &str,
+    code: &str,
+) -> Result<(User, String)> {
+    let user_id = queries::take_mfa_pending_login(&auth.pool, &hash_token(pending_token))
+        .await?
+        .ok_or_else(|| anyhow!("verification expired; log in again"))?;
+    let mfa = queries::get_user_mfa(&auth.pool, user_id)
+        .await?
+        .ok_or_else(|| anyhow!("two-factor authentication is not set up"))?;
+    if !mfa.enabled {
+        anyhow::bail!("two-factor authentication is not set up");
+    }
+    let secret = mfa
+        .totp_secret
+        .as_deref()
+        .ok_or_else(|| anyhow!("two-factor authentication is not set up"))?;
+
+    let code = code.trim();
+    let ok = if crate::mfa::verify_totp(secret, code) {
+        true
+    } else if let Some(idx) = crate::mfa::verify_recovery_code(&mfa.recovery_code_hashes, code) {
+        let mut hashes = mfa.recovery_code_hashes;
+        hashes.remove(idx);
+        queries::mfa_set_recovery_hashes(&auth.pool, user_id, &hashes).await?;
+        true
+    } else {
+        false
+    };
+    if !ok {
+        // Re-store pending so the user can retry within the window.
+        queries::create_mfa_pending_login(&auth.pool, user_id, &hash_token(pending_token), 10)
+            .await?;
+        anyhow::bail!("invalid authentication code");
+    }
+
+    let user = queries::get_user_by_id(&auth.pool, user_id)
+        .await?
+        .ok_or_else(|| anyhow!("user not found"))?;
+    create_kitgit_session(auth, &user).await
+}
+
+/// Create user via Authentik API token, then log them into kitgit.
+pub async fn signup_with_password(
+    auth: &AuthState,
+    username: &str,
+    email: &str,
+    password: &str,
+    display_name: &str,
+) -> Result<LoginOutcome> {
+    let username = sanitize_username(username);
+    if username.len() < 2 {
+        anyhow::bail!("username too short");
+    }
+    if password.len() < 8 {
+        anyhow::bail!("password must be at least 8 characters");
+    }
+    let email = email.trim();
+    if email.is_empty() || !email.contains('@') {
+        anyhow::bail!("valid email required");
+    }
+    let base = auth.config.authentik_base();
+    if base.is_empty() {
+        return Err(anyhow!("identity provider not configured"));
+    }
+    let api_token = auth.config.authentik_token();
+    if api_token.is_empty() {
+        anyhow::bail!("signup unavailable");
+    }
+
+    let client = new_http_client()?;
+    let name = if display_name.trim().is_empty() {
+        username.clone()
+    } else {
+        display_name.trim().to_string()
+    };
+
+    let create_resp = client
+        .post(format!("{base}/api/v3/core/users/"))
+        .headers(bearer_headers(&api_token))
+        .json(&serde_json::json!({
+            "username": username,
+            "name": name,
+            "email": email,
+            "path": "users",
+            "is_active": true,
+            "type": "internal",
+            "attributes": {},
+            "groups": [],
+        }))
+        .send()
+        .await
+        .context("create authentik user")?;
+    let create_status = create_resp.status();
+    let create_body = create_resp.text().await.unwrap_or_default();
+    if !create_status.is_success() {
+        tracing::warn!("create user {create_status}: {}", truncate(&create_body, 400));
+        let lower = create_body.to_lowercase();
+        if lower.contains("unique") || lower.contains("already") || create_status.as_u16() == 400 {
+            anyhow::bail!("username or email already taken");
+        }
+        if create_status.as_u16() == 401 || create_status.as_u16() == 403 {
+            anyhow::bail!("signup unavailable");
+        }
+        anyhow::bail!("could not create account");
+    }
+    let created: serde_json::Value =
+        serde_json::from_str(&create_body).context("parse created user")?;
+    let pk = created
+        .get("pk")
+        .and_then(|v| v.as_i64())
+        .ok_or_else(|| anyhow!("no pk in created user"))?;
+
+    let pw_resp = client
+        .post(format!("{base}/api/v3/core/users/{pk}/set_password/"))
+        .headers(bearer_headers(&api_token))
+        .json(&serde_json::json!({ "password": password }))
+        .send()
+        .await
+        .context("set authentik password")?;
+    if !pw_resp.status().is_success() {
+        let t = pw_resp.text().await.unwrap_or_default();
+        tracing::warn!("set_password failed: {}", truncate(&t, 300));
+        anyhow::bail!("could not set password");
+    }
+
+    login_with_password(auth, &username, password).await
+}
+
+/// Look up Authentik user pk by username (admin API).
+pub async fn authentik_user_pk(auth: &AuthState, username: &str) -> Result<i64> {
+    let base = auth.config.authentik_base();
+    let token = auth.config.authentik_token();
+    if token.is_empty() {
+        anyhow::bail!("account API unavailable");
+    }
+    let client = new_http_client()?;
+    let list = client
+        .get(format!(
+            "{base}/api/v3/core/users/?username={}",
+            urlencoding::encode(username)
+        ))
+        .bearer_auth(&token)
+        .send()
+        .await
+        .context("list users")?;
+    let body: serde_json::Value = list.json().await.context("parse users")?;
+    body.pointer("/results/0/pk")
+        .and_then(|v| v.as_i64())
+        .ok_or_else(|| anyhow!("account not found"))
+}
+
+pub async fn authentik_set_password(auth: &AuthState, pk: i64, password: &str) -> Result<()> {
+    let base = auth.config.authentik_base();
+    let token = auth.config.authentik_token();
+    if token.is_empty() {
+        anyhow::bail!("account API unavailable");
+    }
+    let client = new_http_client()?;
+    let resp = client
+        .post(format!("{base}/api/v3/core/users/{pk}/set_password/"))
+        .bearer_auth(&token)
+        .json(&serde_json::json!({ "password": password }))
+        .send()
+        .await
+        .context("set password")?;
+    if !resp.status().is_success() {
+        anyhow::bail!("could not change password");
+    }
+    Ok(())
+}
+
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..ce341e7
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,166 @@
+use anyhow::{Context, Result};
+use clap::Parser;
+use serde::Deserialize;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use url::Url;
+
+#[derive(Debug, Clone, Parser)]
+#[command(name = "kitgit", about = "self-hosted git. templates, not a product page.")]
+pub struct Config {
+    #[arg(long, env = "KITGIT_DATABASE_URL", default_value = "postgres://kitgit:kitgit@127.0.0.1:5432/kitgit")]
+    pub database_url: String,
+
+    #[arg(long, env = "KITGIT_DATA_DIR", default_value = "./data")]
+    pub data_dir: PathBuf,
+
+    #[arg(long, env = "KITGIT_STATIC_DIR", default_value = "./static")]
+    pub static_dir: PathBuf,
+
+    #[arg(long, env = "KITGIT_HTTP_BIND", default_value = "0.0.0.0:8080")]
+    pub http_bind: SocketAddr,
+
+    #[arg(long, env = "KITGIT_SSH_BIND", default_value = "0.0.0.0:2222")]
+    pub ssh_bind: SocketAddr,
+
+    #[arg(long, env = "KITGIT_PUBLIC_URL", default_value = "http://localhost:8080")]
+    pub public_url: String,
+
+    #[arg(long, env = "KITGIT_SESSION_SECRET", default_value = "change-me-kitgit-session-secret")]
+    pub session_secret: String,
+
+    /// Public OIDC issuer (browser-facing), e.g. http://localhost:9000/application/o/kitgit/
+    #[arg(long, env = "KITGIT_OIDC_ISSUER", default_value = "")]
+    pub oidc_issuer: String,
+
+    /// Optional internal discovery base (container DNS). Falls back to oidc_issuer.
+    /// Example: http://authentik-server:9000/application/o/kitgit/
+    #[arg(long, env = "KITGIT_OIDC_DISCOVERY_URL", default_value = "")]
+    pub oidc_discovery_url: String,
+
+    #[arg(long, env = "KITGIT_OIDC_CLIENT_ID", default_value = "kitgit")]
+    pub oidc_client_id: String,
+
+    #[arg(long, env = "KITGIT_OIDC_CLIENT_SECRET", default_value = "")]
+    pub oidc_client_secret: String,
+
+    #[arg(long, env = "KITGIT_OIDC_REDIRECT_URL", default_value = "http://localhost:8080/auth/callback")]
+    pub oidc_redirect_url: String,
+
+    /// Authentik base URL for flow/API calls (internal Docker DNS preferred).
+    /// Example: http://authentik-server:9000
+    #[arg(long, env = "KITGIT_AUTHENTIK_URL", default_value = "")]
+    pub authentik_url: String,
+
+    /// API token (intent=api) for creating users on signup. Also accepted as AUTHENTIK_TOKEN.
+    #[arg(long, env = "KITGIT_AUTHENTIK_API_TOKEN", default_value = "")]
+    pub authentik_api_token: String,
+
+    #[arg(long, env = "KITGIT_AUTHENTIK_AUTH_FLOW", default_value = "default-authentication-flow")]
+    pub authentik_auth_flow: String,
+
+    #[arg(long, env = "KITGIT_CONFIG", default_value = "")]
+    pub config_file: String,
+}
+
+#[derive(Debug, Deserialize, Default)]
+struct FileConfig {
+    database_url: Option<String>,
+    data_dir: Option<PathBuf>,
+    public_url: Option<String>,
+    session_secret: Option<String>,
+    oidc_issuer: Option<String>,
+    oidc_discovery_url: Option<String>,
+    oidc_client_id: Option<String>,
+    oidc_client_secret: Option<String>,
+    oidc_redirect_url: Option<String>,
+}
+
+impl Config {
+    pub fn load() -> Result<Self> {
+        let mut cfg = Config::parse();
+        if !cfg.config_file.is_empty() {
+            let text = std::fs::read_to_string(&cfg.config_file)
+                .with_context(|| format!("read config {}", cfg.config_file))?;
+            let file: FileConfig = toml::from_str(&text)?;
+            if let Some(v) = file.database_url {
+                cfg.database_url = v;
+            }
+            if let Some(v) = file.data_dir {
+                cfg.data_dir = v;
+            }
+            if let Some(v) = file.public_url {
+                cfg.public_url = v;
+            }
+            if let Some(v) = file.session_secret {
+                cfg.session_secret = v;
+            }
+            if let Some(v) = file.oidc_issuer {
+                cfg.oidc_issuer = v;
+            }
+            if let Some(v) = file.oidc_discovery_url {
+                cfg.oidc_discovery_url = v;
+            }
+            if let Some(v) = file.oidc_client_id {
+                cfg.oidc_client_id = v;
+            }
+            if let Some(v) = file.oidc_client_secret {
+                cfg.oidc_client_secret = v;
+            }
+            if let Some(v) = file.oidc_redirect_url {
+                cfg.oidc_redirect_url = v;
+            }
+        }
+        Ok(cfg)
+    }
+
+    pub fn discovery_issuer(&self) -> &str {
+        if self.oidc_discovery_url.is_empty() {
+            &self.oidc_issuer
+        } else {
+            &self.oidc_discovery_url
+        }
+    }
+
+    /// Base URL for Authentik API / flow executor (no trailing slash).
+    pub fn authentik_base(&self) -> String {
+        if !self.authentik_url.is_empty() {
+            return self.authentik_url.trim_end_matches('/').to_string();
+        }
+        Url::parse(self.discovery_issuer())
+            .ok()
+            .map(|u| {
+                let mut origin = format!("{}://{}", u.scheme(), u.host_str().unwrap_or("localhost"));
+                if let Some(port) = u.port() {
+                    origin.push(':');
+                    origin.push_str(&port.to_string());
+                }
+                origin
+            })
+            .unwrap_or_default()
+    }
+
+    /// Service API token for Authentik admin API (signup).
+    pub fn authentik_token(&self) -> String {
+        if !self.authentik_api_token.is_empty() {
+            return self.authentik_api_token.clone();
+        }
+        std::env::var("AUTHENTIK_TOKEN").unwrap_or_default()
+    }
+
+    pub fn repos_dir(&self) -> PathBuf {
+        self.data_dir.join("repos")
+    }
+
+    pub fn avatars_dir(&self) -> PathBuf {
+        self.data_dir.join("avatars")
+    }
+
+    pub fn releases_dir(&self) -> PathBuf {
+        self.data_dir.join("releases")
+    }
+
+    pub fn ssh_host_key_path(&self) -> PathBuf {
+        self.data_dir.join("ssh_host_ed25519_key")
+    }
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..8d84a74
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,68 @@
+pub mod models;
+pub mod queries;
+
+use anyhow::{Context, Result};
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+use std::path::Path;
+
+pub async fn connect(database_url: &str) -> Result<PgPool> {
+    let pool = PgPoolOptions::new()
+        .max_connections(20)
+        .connect(database_url)
+        .await
+        .context("connect postgres")?;
+    Ok(pool)
+}
+
+pub async fn migrate(pool: &PgPool, migrations_dir: &Path) -> Result<()> {
+    sqlx::query(
+        r#"
+        CREATE TABLE IF NOT EXISTS schema_migrations (
+            filename TEXT PRIMARY KEY,
+            applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
+        )
+        "#,
+    )
+    .execute(pool)
+    .await?;
+
+    let mut files: Vec<_> = std::fs::read_dir(migrations_dir)
+        .with_context(|| format!("read migrations {}", migrations_dir.display()))?
+        .filter_map(|e| e.ok())
+        .map(|e| e.path())
+        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("sql"))
+        .collect();
+    files.sort();
+
+    for path in files {
+        let filename = path
+            .file_name()
+            .and_then(|s| s.to_str())
+            .unwrap_or_default()
+            .to_string();
+        let applied: Option<(String,)> =
+            sqlx::query_as("SELECT filename FROM schema_migrations WHERE filename = $1")
+                .bind(&filename)
+                .fetch_optional(pool)
+                .await?;
+        if applied.is_some() {
+            continue;
+        }
+        let sql = std::fs::read_to_string(&path)
+            .with_context(|| format!("read {}", path.display()))?;
+        let sql = sql.strip_prefix('\u{feff}').unwrap_or(&sql);
+        let mut tx = pool.begin().await?;
+        sqlx::raw_sql(sql)
+            .execute(&mut *tx)
+            .await
+            .with_context(|| format!("apply migration {filename}"))?;
+        sqlx::query("INSERT INTO schema_migrations (filename) VALUES ($1)")
+            .bind(&filename)
+            .execute(&mut *tx)
+            .await?;
+        tx.commit().await?;
+        tracing::info!("applied migration {filename}");
+    }
+    Ok(())
+}
diff --git a/src/db/models.rs b/src/db/models.rs
new file mode 100644
index 0000000..8f606e6
--- /dev/null
+++ b/src/db/models.rs
@@ -0,0 +1,239 @@
+use chrono::{DateTime, NaiveDate, Utc};
+use serde::{Deserialize, Serialize};
+use sqlx::FromRow;
+use uuid::Uuid;
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct User {
+    pub id: Uuid,
+    pub oidc_sub: String,
+    pub username: String,
+    pub display_name: String,
+    pub email: String,
+    pub bio: String,
+    pub avatar_path: Option<String>,
+    pub avatar_url: Option<String>,
+    pub is_site_admin: bool,
+    pub show_email: bool,
+    pub vigilant_mode: bool,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct UserMfa {
+    pub user_id: Uuid,
+    pub totp_secret: Option<String>,
+    pub pending_secret: Option<String>,
+    pub enabled: bool,
+    pub recovery_code_hashes: Vec<String>,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct Session {
+    pub id: Uuid,
+    pub user_id: Uuid,
+    pub token_hash: String,
+    pub expires_at: DateTime<Utc>,
+    pub created_at: DateTime<Utc>,
+    pub user_agent: String,
+    pub ip_address: String,
+    pub last_seen_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct Repository {
+    pub id: Uuid,
+    pub owner_id: Uuid,
+    pub name: String,
+    pub description: String,
+    pub visibility: String,
+    pub default_branch: String,
+    pub archived: bool,
+    pub issues_enabled: bool,
+    pub pulls_enabled: bool,
+    pub releases_enabled: bool,
+    pub allow_merge: bool,
+    pub allow_squash: bool,
+    pub allow_rebase: bool,
+    pub default_merge_style: String,
+    pub protect_default_branch: bool,
+    pub protect_block_force_push: bool,
+    pub fork_of_id: Option<Uuid>,
+    pub stars_count: i32,
+    pub watches_count: i32,
+    pub forks_count: i32,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct UserEmail {
+    pub id: Uuid,
+    pub user_id: Uuid,
+    pub email: String,
+    pub verified: bool,
+    pub is_primary: bool,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct GpgKey {
+    pub id: Uuid,
+    pub user_id: Uuid,
+    pub name: String,
+    pub public_key: String,
+    pub fingerprint: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct BranchRule {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub pattern: String,
+    pub require_pr: bool,
+    pub block_force_push: bool,
+    pub allow_deletions: bool,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct CommentReaction {
+    pub comment_id: Uuid,
+    pub user_id: Uuid,
+    pub emoji: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct SshKey {
+    pub id: Uuid,
+    pub user_id: Uuid,
+    pub name: String,
+    pub public_key: String,
+    pub fingerprint: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct Collaborator {
+    pub repo_id: Uuid,
+    pub user_id: Uuid,
+    pub role: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct ActivityEvent {
+    pub id: i64,
+    pub actor_id: Option<Uuid>,
+    pub repo_id: Option<Uuid>,
+    pub kind: String,
+    pub summary: String,
+    pub payload: serde_json::Value,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct Issue {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub number: i32,
+    pub author_id: Uuid,
+    pub title: String,
+    pub body: String,
+    pub state: String,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+    pub closed_at: Option<DateTime<Utc>>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct PullRequest {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub number: i32,
+    pub author_id: Uuid,
+    pub title: String,
+    pub body: String,
+    pub state: String,
+    pub source_branch: String,
+    pub target_branch: String,
+    pub merge_commit: Option<String>,
+    pub source_repo_id: Option<Uuid>,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+    pub merged_at: Option<DateTime<Utc>>,
+    pub closed_at: Option<DateTime<Utc>>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct Comment {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub author_id: Uuid,
+    pub issue_id: Option<Uuid>,
+    pub pull_id: Option<Uuid>,
+    pub body: String,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct Release {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub author_id: Uuid,
+    pub tag_name: String,
+    pub title: String,
+    pub body: String,
+    pub is_prerelease: bool,
+    pub is_draft: bool,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct ReleaseAsset {
+    pub id: Uuid,
+    pub release_id: Uuid,
+    pub filename: String,
+    pub stored_path: String,
+    pub size_bytes: i64,
+    pub content_type: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
+pub struct CommitDay {
+    pub day: NaiveDate,
+    pub count: i32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Access {
+    None,
+    Read,
+    Write,
+    Admin,
+    Owner,
+}
+
+impl Access {
+    pub fn can_read(self) -> bool {
+        !matches!(self, Access::None)
+    }
+    pub fn can_write(self) -> bool {
+        matches!(self, Access::Write | Access::Admin | Access::Owner)
+    }
+    pub fn can_admin(self) -> bool {
+        matches!(self, Access::Admin | Access::Owner)
+    }
+    pub fn can_owner(self) -> bool {
+        matches!(self, Access::Owner)
+    }
+}
diff --git a/src/db/queries.rs b/src/db/queries.rs
new file mode 100644
index 0000000..89777d0
--- /dev/null
+++ b/src/db/queries.rs
@@ -0,0 +1,1845 @@
+use super::models::*;
+use anyhow::Result;
+use chrono::{DateTime, NaiveDate, Utc};
+use sqlx::PgPool;
+use uuid::Uuid;
+
+pub async fn upsert_user_from_oidc(
+    pool: &PgPool,
+    oidc_sub: &str,
+    username: &str,
+    display_name: &str,
+    email: &str,
+    avatar_url: Option<&str>,
+) -> Result<User> {
+    let avatar_url = avatar_url
+        .filter(|u| !u.is_empty() && !u.starts_with("/avatars/"));
+    let user = sqlx::query_as::<_, User>(
+        r#"
+        INSERT INTO users (oidc_sub, username, display_name, email, avatar_url)
+        VALUES ($1, $2, $3, $4, $5)
+        ON CONFLICT (oidc_sub) DO UPDATE SET
+            display_name = EXCLUDED.display_name,
+            email = EXCLUDED.email,
+            avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
+            updated_at = now()
+        RETURNING *
+        "#,
+    )
+    .bind(oidc_sub)
+    .bind(username)
+    .bind(display_name)
+    .bind(email)
+    .bind(avatar_url)
+    .fetch_one(pool)
+    .await?;
+    Ok(user)
+}
+
+pub async fn site_admin_count(pool: &PgPool) -> Result<i64> {
+    let (n,): (i64,) =
+        sqlx::query_as("SELECT COUNT(*) FROM users WHERE is_site_admin = TRUE")
+            .fetch_one(pool)
+            .await?;
+    Ok(n)
+}
+
+pub async fn set_site_admin(pool: &PgPool, id: Uuid, is_admin: bool) -> Result<User> {
+    Ok(sqlx::query_as::<_, User>(
+        "UPDATE users SET is_site_admin = $2, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(is_admin)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_users(pool: &PgPool) -> Result<Vec<User>> {
+    Ok(sqlx::query_as::<_, User>(
+        "SELECT * FROM users ORDER BY username",
+    )
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
+    Ok(sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
+        .bind(id)
+        .fetch_optional(pool)
+        .await?)
+}
+
+pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>> {
+    Ok(
+        sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
+            .bind(username)
+            .fetch_optional(pool)
+            .await?,
+    )
+}
+
+pub async fn update_user_profile(
+    pool: &PgPool,
+    id: Uuid,
+    display_name: &str,
+    bio: &str,
+    avatar_path: Option<&str>,
+) -> Result<User> {
+    Ok(sqlx::query_as::<_, User>(
+        r#"
+        UPDATE users SET
+            display_name = $2,
+            bio = $3,
+            avatar_path = COALESCE($4, avatar_path),
+            avatar_url = CASE WHEN $4 IS NOT NULL THEN NULL ELSE avatar_url END,
+            updated_at = now()
+        WHERE id = $1
+        RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(display_name)
+    .bind(bio)
+    .bind(avatar_path)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn get_setting(pool: &PgPool, key: &str) -> Result<String> {
+    let row: Option<(String,)> = sqlx::query_as("SELECT value FROM site_settings WHERE key = $1")
+        .bind(key)
+        .fetch_optional(pool)
+        .await?;
+    Ok(row.map(|r| r.0).unwrap_or_default())
+}
+
+pub async fn set_setting(pool: &PgPool, key: &str, value: &str) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO site_settings (key, value) VALUES ($1, $2)
+        ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+        "#,
+    )
+    .bind(key)
+    .bind(value)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+
+pub async fn create_session(pool: &PgPool, user_id: Uuid, token_hash: &str, days: i64) -> Result<()> {
+    let expires = Utc::now() + chrono::Duration::days(days);
+    sqlx::query(
+        "INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
+    )
+    .bind(user_id)
+    .bind(token_hash)
+    .bind(expires)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn user_from_session(pool: &PgPool, token_hash: &str) -> Result<Option<User>> {
+    Ok(sqlx::query_as::<_, User>(
+        r#"
+        SELECT u.* FROM users u
+        JOIN sessions s ON s.user_id = u.id
+        WHERE s.token_hash = $1 AND s.expires_at > now()
+        "#,
+    )
+    .bind(token_hash)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn delete_session(pool: &PgPool, token_hash: &str) -> Result<()> {
+    sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
+        .bind(token_hash)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn store_oidc_pending(
+    pool: &PgPool,
+    state: &str,
+    pkce_verifier: &str,
+    nonce: &str,
+) -> Result<()> {
+    sqlx::query(
+        "INSERT INTO oidc_pending (state, pkce_verifier, nonce) VALUES ($1, $2, $3)
+         ON CONFLICT (state) DO UPDATE SET pkce_verifier = EXCLUDED.pkce_verifier, nonce = EXCLUDED.nonce",
+    )
+    .bind(state)
+    .bind(pkce_verifier)
+    .bind(nonce)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn take_oidc_pending(pool: &PgPool, state: &str) -> Result<Option<(String, String)>> {
+    let row: Option<(String, String)> = sqlx::query_as(
+        "DELETE FROM oidc_pending WHERE state = $1 RETURNING pkce_verifier, nonce",
+    )
+    .bind(state)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row)
+}
+
+pub async fn create_repo(
+    pool: &PgPool,
+    owner_id: Uuid,
+    name: &str,
+    description: &str,
+    visibility: &str,
+) -> Result<Repository> {
+    let mut tx = pool.begin().await?;
+    let repo = sqlx::query_as::<_, Repository>(
+        r#"
+        INSERT INTO repositories (owner_id, name, description, visibility)
+        VALUES ($1, $2, $3, $4)
+        RETURNING *
+        "#,
+    )
+    .bind(owner_id)
+    .bind(name)
+    .bind(description)
+    .bind(visibility)
+    .fetch_one(&mut *tx)
+    .await?;
+    sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
+        .bind(repo.id)
+        .execute(&mut *tx)
+        .await?;
+    sqlx::query("INSERT INTO language_stats (repo_id) VALUES ($1)")
+        .bind(repo.id)
+        .execute(&mut *tx)
+        .await?;
+    tx.commit().await?;
+    Ok(repo)
+}
+
+pub async fn get_repo(pool: &PgPool, owner: &str, name: &str) -> Result<Option<(Repository, User)>> {
+    let owner_user = match get_user_by_username(pool, owner).await? {
+        Some(u) => u,
+        None => return Ok(None),
+    };
+    let repo = sqlx::query_as::<_, Repository>(
+        "SELECT * FROM repositories WHERE owner_id = $1 AND name = $2",
+    )
+    .bind(owner_user.id)
+    .bind(name)
+    .fetch_optional(pool)
+    .await?;
+    Ok(repo.map(|r| (r, owner_user)))
+}
+
+pub async fn get_repo_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Repository>> {
+    Ok(
+        sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE id = $1")
+            .bind(id)
+            .fetch_optional(pool)
+            .await?,
+    )
+}
+
+pub async fn list_user_repos(pool: &PgPool, owner_id: Uuid, viewer: Option<Uuid>) -> Result<Vec<Repository>> {
+    Ok(sqlx::query_as::<_, Repository>(
+        r#"
+        SELECT r.* FROM repositories r
+        WHERE r.owner_id = $1
+          AND (
+            r.visibility = 'public'
+            OR r.owner_id = $2
+            OR EXISTS (
+              SELECT 1 FROM collaborators c
+              WHERE c.repo_id = r.id AND c.user_id = $2
+            )
+          )
+        ORDER BY r.updated_at DESC
+        "#,
+    )
+    .bind(owner_id)
+    .bind(viewer)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn list_public_repos(
+    pool: &PgPool,
+    search: Option<&str>,
+    limit: i64,
+) -> Result<Vec<(Repository, String)>> {
+    #[derive(sqlx::FromRow)]
+    struct Row {
+        id: Uuid,
+        owner_id: Uuid,
+        name: String,
+        description: String,
+        visibility: String,
+        default_branch: String,
+        archived: bool,
+        issues_enabled: bool,
+        pulls_enabled: bool,
+        releases_enabled: bool,
+        allow_merge: bool,
+        allow_squash: bool,
+        allow_rebase: bool,
+        default_merge_style: String,
+        protect_default_branch: bool,
+        protect_block_force_push: bool,
+        fork_of_id: Option<Uuid>,
+        stars_count: i32,
+        watches_count: i32,
+        forks_count: i32,
+        created_at: DateTime<Utc>,
+        updated_at: DateTime<Utc>,
+        owner_username: String,
+    }
+
+    let q = search.map(|s| s.trim()).filter(|s| !s.is_empty());
+    let rows: Vec<Row> = if let Some(term) = q {
+        let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_"));
+        sqlx::query_as(
+            r#"
+            SELECT r.*, u.username AS owner_username
+            FROM repositories r
+            JOIN users u ON u.id = r.owner_id
+            WHERE r.visibility = 'public'
+              AND r.archived = false
+              AND (
+                r.name ILIKE $1 ESCAPE '\'
+                OR r.description ILIKE $1 ESCAPE '\'
+                OR u.username ILIKE $1 ESCAPE '\'
+              )
+            ORDER BY r.updated_at DESC
+            LIMIT $2
+            "#,
+        )
+        .bind(like)
+        .bind(limit)
+        .fetch_all(pool)
+        .await?
+    } else {
+        sqlx::query_as(
+            r#"
+            SELECT r.*, u.username AS owner_username
+            FROM repositories r
+            JOIN users u ON u.id = r.owner_id
+            WHERE r.visibility = 'public'
+              AND r.archived = false
+            ORDER BY r.updated_at DESC
+            LIMIT $1
+            "#,
+        )
+        .bind(limit)
+        .fetch_all(pool)
+        .await?
+    };
+
+    Ok(rows
+        .into_iter()
+        .map(|r| {
+            (
+                Repository {
+                    id: r.id,
+                    owner_id: r.owner_id,
+                    name: r.name,
+                    description: r.description,
+                    visibility: r.visibility,
+                    default_branch: r.default_branch,
+                    archived: r.archived,
+                    issues_enabled: r.issues_enabled,
+                    pulls_enabled: r.pulls_enabled,
+                    releases_enabled: r.releases_enabled,
+                    allow_merge: r.allow_merge,
+                    allow_squash: r.allow_squash,
+                    allow_rebase: r.allow_rebase,
+                    default_merge_style: r.default_merge_style,
+                    protect_default_branch: r.protect_default_branch,
+                    protect_block_force_push: r.protect_block_force_push,
+                    fork_of_id: r.fork_of_id,
+                    stars_count: r.stars_count,
+                    watches_count: r.watches_count,
+                    forks_count: r.forks_count,
+                    created_at: r.created_at,
+                    updated_at: r.updated_at,
+                },
+                r.owner_username,
+            )
+        })
+        .collect())
+}
+
+pub async fn delete_repo(pool: &PgPool, id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM repositories WHERE id = $1")
+        .bind(id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn update_repo_settings(
+    pool: &PgPool,
+    id: Uuid,
+    description: &str,
+    visibility: &str,
+    default_branch: &str,
+    issues_enabled: bool,
+    pulls_enabled: bool,
+    releases_enabled: bool,
+    allow_merge: bool,
+    allow_squash: bool,
+    allow_rebase: bool,
+    default_merge_style: &str,
+    protect_default_branch: bool,
+    protect_block_force_push: bool,
+) -> Result<Repository> {
+    Ok(sqlx::query_as::<_, Repository>(
+        r#"
+        UPDATE repositories SET
+            description = $2,
+            visibility = $3,
+            default_branch = $4,
+            issues_enabled = $5,
+            pulls_enabled = $6,
+            releases_enabled = $7,
+            allow_merge = $8,
+            allow_squash = $9,
+            allow_rebase = $10,
+            default_merge_style = $11,
+            protect_default_branch = $12,
+            protect_block_force_push = $13,
+            updated_at = now()
+        WHERE id = $1
+        RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(description)
+    .bind(visibility)
+    .bind(default_branch)
+    .bind(issues_enabled)
+    .bind(pulls_enabled)
+    .bind(releases_enabled)
+    .bind(allow_merge)
+    .bind(allow_squash)
+    .bind(allow_rebase)
+    .bind(default_merge_style)
+    .bind(protect_default_branch)
+    .bind(protect_block_force_push)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn transfer_repo(pool: &PgPool, id: Uuid, new_owner_id: Uuid) -> Result<Repository> {
+    Ok(sqlx::query_as::<_, Repository>(
+        "UPDATE repositories SET owner_id = $2, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(new_owner_id)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn set_archived(pool: &PgPool, id: Uuid, archived: bool) -> Result<()> {
+    sqlx::query("UPDATE repositories SET archived = $2, updated_at = now() WHERE id = $1")
+        .bind(id)
+        .bind(archived)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn repo_access(pool: &PgPool, repo: &Repository, user_id: Option<Uuid>) -> Result<Access> {
+    if let Some(uid) = user_id {
+        if uid == repo.owner_id {
+            return Ok(Access::Owner);
+        }
+        if let Some(c) = sqlx::query_as::<_, Collaborator>(
+            "SELECT * FROM collaborators WHERE repo_id = $1 AND user_id = $2",
+        )
+        .bind(repo.id)
+        .bind(uid)
+        .fetch_optional(pool)
+        .await?
+        {
+            return Ok(match c.role.as_str() {
+                "admin" => Access::Admin,
+                "write" => Access::Write,
+                _ => Access::Read,
+            });
+        }
+    }
+    if repo.visibility == "public" {
+        return Ok(Access::Read);
+    }
+    Ok(Access::None)
+}
+
+pub async fn add_collaborator(pool: &PgPool, repo_id: Uuid, user_id: Uuid, role: &str) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO collaborators (repo_id, user_id, role) VALUES ($1, $2, $3)
+        ON CONFLICT (repo_id, user_id) DO UPDATE SET role = EXCLUDED.role
+        "#,
+    )
+    .bind(repo_id)
+    .bind(user_id)
+    .bind(role)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn remove_collaborator(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM collaborators WHERE repo_id = $1 AND user_id = $2")
+        .bind(repo_id)
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn list_collaborators(pool: &PgPool, repo_id: Uuid) -> Result<Vec<(Collaborator, User)>> {
+    let rows = sqlx::query_as::<_, Collaborator>(
+        "SELECT * FROM collaborators WHERE repo_id = $1 ORDER BY created_at",
+    )
+    .bind(repo_id)
+    .fetch_all(pool)
+    .await?;
+    let mut out = Vec::new();
+    for c in rows {
+        if let Some(u) = get_user_by_id(pool, c.user_id).await? {
+            out.push((c, u));
+        }
+    }
+    Ok(out)
+}
+
+pub async fn add_ssh_key(
+    pool: &PgPool,
+    user_id: Uuid,
+    name: &str,
+    public_key: &str,
+    fingerprint: &str,
+) -> Result<SshKey> {
+    Ok(sqlx::query_as::<_, SshKey>(
+        r#"
+        INSERT INTO ssh_keys (user_id, name, public_key, fingerprint)
+        VALUES ($1, $2, $3, $4)
+        RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(name)
+    .bind(public_key)
+    .bind(fingerprint)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_ssh_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<SshKey>> {
+    Ok(
+        sqlx::query_as::<_, SshKey>("SELECT * FROM ssh_keys WHERE user_id = $1 ORDER BY created_at")
+            .bind(user_id)
+            .fetch_all(pool)
+            .await?,
+    )
+}
+
+pub async fn delete_ssh_key(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2")
+        .bind(id)
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result<Option<User>> {
+    // OpenSSH prints SHA256 fingerprints without `=` padding; tolerate either form.
+    let normalized = fingerprint.trim_end_matches('=');
+    Ok(sqlx::query_as::<_, User>(
+        r#"
+        SELECT u.* FROM users u
+        JOIN ssh_keys k ON k.user_id = u.id
+        WHERE rtrim(k.fingerprint, '=') = $1
+        "#,
+    )
+    .bind(normalized)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn record_activity(
+    pool: &PgPool,
+    actor_id: Option<Uuid>,
+    repo_id: Option<Uuid>,
+    kind: &str,
+    summary: &str,
+    payload: serde_json::Value,
+) -> Result<()> {
+    sqlx::query(
+        "INSERT INTO activity_events (actor_id, repo_id, kind, summary, payload) VALUES ($1,$2,$3,$4,$5)",
+    )
+    .bind(actor_id)
+    .bind(repo_id)
+    .bind(kind)
+    .bind(summary)
+    .bind(payload)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn latest_activity(pool: &PgPool, limit: i64) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> {
+    let events = sqlx::query_as::<_, ActivityEvent>(
+        "SELECT * FROM activity_events ORDER BY created_at DESC LIMIT $1",
+    )
+    .bind(limit)
+    .fetch_all(pool)
+    .await?;
+    hydrate_activity(pool, events).await
+}
+
+pub async fn latest_activity_for_user(
+    pool: &PgPool,
+    user_id: Uuid,
+    limit: i64,
+) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> {
+    let events = sqlx::query_as::<_, ActivityEvent>(
+        "SELECT * FROM activity_events WHERE actor_id = $1 ORDER BY created_at DESC LIMIT $2",
+    )
+    .bind(user_id)
+    .bind(limit)
+    .fetch_all(pool)
+    .await?;
+    hydrate_activity(pool, events).await
+}
+
+async fn hydrate_activity(
+    pool: &PgPool,
+    events: Vec<ActivityEvent>,
+) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> {
+    let mut out = Vec::new();
+    for e in events {
+        let actor = match e.actor_id {
+            Some(id) => get_user_by_id(pool, id).await?,
+            None => None,
+        };
+        let (repo, owner_name) = match e.repo_id {
+            Some(id) => {
+                let r = get_repo_by_id(pool, id).await?;
+                let oname = if let Some(ref repo) = r {
+                    get_user_by_id(pool, repo.owner_id)
+                        .await?
+                        .map(|u| u.username)
+                } else {
+                    None
+                };
+                (r, oname)
+            }
+            None => (None, None),
+        };
+        out.push((e, actor, repo, owner_name));
+    }
+    Ok(out)
+}
+
+pub async fn bump_commit_activity(pool: &PgPool, user_id: Uuid, day: NaiveDate, n: i32) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO commit_activity (user_id, day, count) VALUES ($1, $2, $3)
+        ON CONFLICT (user_id, day) DO UPDATE SET count = commit_activity.count + EXCLUDED.count
+        "#,
+    )
+    .bind(user_id)
+    .bind(day)
+    .bind(n)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn commit_graph(pool: &PgPool, user_id: Uuid, days: i64) -> Result<Vec<CommitDay>> {
+    Ok(sqlx::query_as::<_, CommitDay>(
+        r#"
+        SELECT day, count FROM commit_activity
+        WHERE user_id = $1 AND day >= (CURRENT_DATE - $2::int)
+        ORDER BY day
+        "#,
+    )
+    .bind(user_id)
+    .bind(days as i32)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn set_language_stats(pool: &PgPool, repo_id: Uuid, stats: serde_json::Value) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO language_stats (repo_id, stats, updated_at) VALUES ($1, $2, now())
+        ON CONFLICT (repo_id) DO UPDATE SET stats = EXCLUDED.stats, updated_at = now()
+        "#,
+    )
+    .bind(repo_id)
+    .bind(stats)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn get_language_stats(pool: &PgPool, repo_id: Uuid) -> Result<serde_json::Value> {
+    let row: Option<(serde_json::Value,)> =
+        sqlx::query_as("SELECT stats FROM language_stats WHERE repo_id = $1")
+            .bind(repo_id)
+            .fetch_optional(pool)
+            .await?;
+    Ok(row.map(|r| r.0).unwrap_or_else(|| serde_json::json!({})))
+}
+
+pub async fn next_issue_number(pool: &PgPool, repo_id: Uuid) -> Result<i32> {
+    let (n,): (i32,) = sqlx::query_as(
+        "UPDATE repo_counters SET next_issue = next_issue + 1 WHERE repo_id = $1 RETURNING next_issue - 1",
+    )
+    .bind(repo_id)
+    .fetch_one(pool)
+    .await?;
+    Ok(n)
+}
+
+pub async fn next_pull_number(pool: &PgPool, repo_id: Uuid) -> Result<i32> {
+    let (n,): (i32,) = sqlx::query_as(
+        "UPDATE repo_counters SET next_pull = next_pull + 1 WHERE repo_id = $1 RETURNING next_pull - 1",
+    )
+    .bind(repo_id)
+    .fetch_one(pool)
+    .await?;
+    Ok(n)
+}
+
+pub async fn create_issue(
+    pool: &PgPool,
+    repo_id: Uuid,
+    author_id: Uuid,
+    number: i32,
+    title: &str,
+    body: &str,
+) -> Result<Issue> {
+    Ok(sqlx::query_as::<_, Issue>(
+        r#"
+        INSERT INTO issues (repo_id, number, author_id, title, body)
+        VALUES ($1,$2,$3,$4,$5) RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(number)
+    .bind(author_id)
+    .bind(title)
+    .bind(body)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_issues(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<Issue>> {
+    if let Some(s) = state {
+        Ok(sqlx::query_as::<_, Issue>(
+            "SELECT * FROM issues WHERE repo_id = $1 AND state = $2 ORDER BY number DESC",
+        )
+        .bind(repo_id)
+        .bind(s)
+        .fetch_all(pool)
+        .await?)
+    } else {
+        Ok(sqlx::query_as::<_, Issue>(
+            "SELECT * FROM issues WHERE repo_id = $1 ORDER BY number DESC",
+        )
+        .bind(repo_id)
+        .fetch_all(pool)
+        .await?)
+    }
+}
+
+pub async fn get_issue(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<Issue>> {
+    Ok(
+        sqlx::query_as::<_, Issue>("SELECT * FROM issues WHERE repo_id = $1 AND number = $2")
+            .bind(repo_id)
+            .bind(number)
+            .fetch_optional(pool)
+            .await?,
+    )
+}
+
+pub async fn set_issue_state(pool: &PgPool, id: Uuid, state: &str) -> Result<Issue> {
+    let closed = if state == "closed" {
+        Some(Utc::now())
+    } else {
+        None
+    };
+    Ok(sqlx::query_as::<_, Issue>(
+        "UPDATE issues SET state = $2, closed_at = $3, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(state)
+    .bind(closed)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn create_pull(
+    pool: &PgPool,
+    repo_id: Uuid,
+    author_id: Uuid,
+    number: i32,
+    title: &str,
+    body: &str,
+    source: &str,
+    target: &str,
+) -> Result<PullRequest> {
+    create_pull_ex(pool, repo_id, author_id, number, title, body, source, target, None).await
+}
+
+pub async fn create_pull_ex(
+    pool: &PgPool,
+    repo_id: Uuid,
+    author_id: Uuid,
+    number: i32,
+    title: &str,
+    body: &str,
+    source: &str,
+    target: &str,
+    source_repo_id: Option<Uuid>,
+) -> Result<PullRequest> {
+    Ok(sqlx::query_as::<_, PullRequest>(
+        r#"
+        INSERT INTO pull_requests
+          (repo_id, number, author_id, title, body, source_branch, target_branch, source_repo_id)
+        VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(number)
+    .bind(author_id)
+    .bind(title)
+    .bind(body)
+    .bind(source)
+    .bind(target)
+    .bind(source_repo_id)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_pulls(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<PullRequest>> {
+    if let Some(s) = state {
+        Ok(sqlx::query_as::<_, PullRequest>(
+            "SELECT * FROM pull_requests WHERE repo_id = $1 AND state = $2 ORDER BY number DESC",
+        )
+        .bind(repo_id)
+        .bind(s)
+        .fetch_all(pool)
+        .await?)
+    } else {
+        Ok(sqlx::query_as::<_, PullRequest>(
+            "SELECT * FROM pull_requests WHERE repo_id = $1 ORDER BY number DESC",
+        )
+        .bind(repo_id)
+        .fetch_all(pool)
+        .await?)
+    }
+}
+
+pub async fn get_pull(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<PullRequest>> {
+    Ok(sqlx::query_as::<_, PullRequest>(
+        "SELECT * FROM pull_requests WHERE repo_id = $1 AND number = $2",
+    )
+    .bind(repo_id)
+    .bind(number)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn set_pull_state(
+    pool: &PgPool,
+    id: Uuid,
+    state: &str,
+    merge_commit: Option<&str>,
+) -> Result<PullRequest> {
+    let now = Utc::now();
+    Ok(sqlx::query_as::<_, PullRequest>(
+        r#"
+        UPDATE pull_requests SET
+            state = $2,
+            merge_commit = COALESCE($3, merge_commit),
+            merged_at = CASE WHEN $2 = 'merged' THEN $4 ELSE merged_at END,
+            closed_at = CASE WHEN $2 IN ('closed','merged') THEN $4 ELSE NULL END,
+            updated_at = now()
+        WHERE id = $1
+        RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(state)
+    .bind(merge_commit)
+    .bind(now)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn add_comment(
+    pool: &PgPool,
+    repo_id: Uuid,
+    author_id: Uuid,
+    issue_id: Option<Uuid>,
+    pull_id: Option<Uuid>,
+    body: &str,
+) -> Result<Comment> {
+    Ok(sqlx::query_as::<_, Comment>(
+        r#"
+        INSERT INTO comments (repo_id, author_id, issue_id, pull_id, body)
+        VALUES ($1,$2,$3,$4,$5) RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(author_id)
+    .bind(issue_id)
+    .bind(pull_id)
+    .bind(body)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_issue_comments(pool: &PgPool, issue_id: Uuid) -> Result<Vec<Comment>> {
+    Ok(sqlx::query_as::<_, Comment>(
+        "SELECT * FROM comments WHERE issue_id = $1 ORDER BY created_at",
+    )
+    .bind(issue_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn list_pull_comments(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Comment>> {
+    Ok(sqlx::query_as::<_, Comment>(
+        "SELECT * FROM comments WHERE pull_id = $1 ORDER BY created_at",
+    )
+    .bind(pull_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn create_release(
+    pool: &PgPool,
+    repo_id: Uuid,
+    author_id: Uuid,
+    tag_name: &str,
+    title: &str,
+    body: &str,
+    is_prerelease: bool,
+    is_draft: bool,
+) -> Result<Release> {
+    Ok(sqlx::query_as::<_, Release>(
+        r#"
+        INSERT INTO releases (repo_id, author_id, tag_name, title, body, is_prerelease, is_draft)
+        VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(author_id)
+    .bind(tag_name)
+    .bind(title)
+    .bind(body)
+    .bind(is_prerelease)
+    .bind(is_draft)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn update_release(
+    pool: &PgPool,
+    release_id: Uuid,
+    tag_name: &str,
+    title: &str,
+    body: &str,
+    is_prerelease: bool,
+    is_draft: bool,
+) -> Result<Release> {
+    Ok(sqlx::query_as::<_, Release>(
+        r#"
+        UPDATE releases
+        SET tag_name = $2,
+            title = $3,
+            body = $4,
+            is_prerelease = $5,
+            is_draft = $6,
+            updated_at = now()
+        WHERE id = $1
+        RETURNING *
+        "#,
+    )
+    .bind(release_id)
+    .bind(tag_name)
+    .bind(title)
+    .bind(body)
+    .bind(is_prerelease)
+    .bind(is_draft)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn delete_release(pool: &PgPool, release_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM releases WHERE id = $1")
+        .bind(release_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn list_releases(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Release>> {
+    Ok(sqlx::query_as::<_, Release>(
+        "SELECT * FROM releases WHERE repo_id = $1 ORDER BY created_at DESC",
+    )
+    .bind(repo_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn get_release(pool: &PgPool, repo_id: Uuid, tag: &str) -> Result<Option<Release>> {
+    Ok(sqlx::query_as::<_, Release>(
+        "SELECT * FROM releases WHERE repo_id = $1 AND tag_name = $2",
+    )
+    .bind(repo_id)
+    .bind(tag)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn add_release_asset(
+    pool: &PgPool,
+    release_id: Uuid,
+    filename: &str,
+    stored_path: &str,
+    size_bytes: i64,
+    content_type: &str,
+) -> Result<ReleaseAsset> {
+    Ok(sqlx::query_as::<_, ReleaseAsset>(
+        r#"
+        INSERT INTO release_assets (release_id, filename, stored_path, size_bytes, content_type)
+        VALUES ($1,$2,$3,$4,$5) RETURNING *
+        "#,
+    )
+    .bind(release_id)
+    .bind(filename)
+    .bind(stored_path)
+    .bind(size_bytes)
+    .bind(content_type)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_release_assets(pool: &PgPool, release_id: Uuid) -> Result<Vec<ReleaseAsset>> {
+    Ok(sqlx::query_as::<_, ReleaseAsset>(
+        "SELECT * FROM release_assets WHERE release_id = $1 ORDER BY filename",
+    )
+    .bind(release_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn get_asset(pool: &PgPool, id: Uuid) -> Result<Option<ReleaseAsset>> {
+    Ok(
+        sqlx::query_as::<_, ReleaseAsset>("SELECT * FROM release_assets WHERE id = $1")
+            .bind(id)
+            .fetch_optional(pool)
+            .await?,
+    )
+}
+
+pub async fn delete_release_asset(pool: &PgPool, id: Uuid) -> Result<Option<ReleaseAsset>> {
+    Ok(sqlx::query_as::<_, ReleaseAsset>(
+        "DELETE FROM release_assets WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn rename_release_tag(
+    pool: &PgPool,
+    repo_id: Uuid,
+    old_tag: &str,
+    new_tag: &str,
+) -> Result<Option<Release>> {
+    Ok(sqlx::query_as::<_, Release>(
+        r#"
+        UPDATE releases
+        SET tag_name = $3, updated_at = now()
+        WHERE repo_id = $1 AND tag_name = $2
+        RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(old_tag)
+    .bind(new_tag)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn rewrite_asset_paths_for_tag(
+    pool: &PgPool,
+    release_id: Uuid,
+    old_prefix: &str,
+    new_prefix: &str,
+) -> Result<()> {
+    sqlx::query(
+        r#"
+        UPDATE release_assets
+        SET stored_path = $3 || substr(stored_path, length($2) + 1)
+        WHERE release_id = $1 AND stored_path LIKE $2 || '%'
+        "#,
+    )
+    .bind(release_id)
+    .bind(old_prefix)
+    .bind(new_prefix)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+
+// ── stars / watches / forks ──────────────────────────────────────────────────
+
+pub async fn toggle_star(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> {
+    let existing: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT user_id FROM repo_stars WHERE repo_id = $1 AND user_id = $2",
+    )
+    .bind(repo_id)
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?;
+    if existing.is_some() {
+        sqlx::query("DELETE FROM repo_stars WHERE repo_id = $1 AND user_id = $2")
+            .bind(repo_id)
+            .bind(user_id)
+            .execute(pool)
+            .await?;
+        sqlx::query("UPDATE repositories SET stars_count = GREATEST(stars_count - 1, 0) WHERE id = $1")
+            .bind(repo_id)
+            .execute(pool)
+            .await?;
+        Ok(false)
+    } else {
+        sqlx::query("INSERT INTO repo_stars (repo_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
+            .bind(repo_id)
+            .bind(user_id)
+            .execute(pool)
+            .await?;
+        sqlx::query("UPDATE repositories SET stars_count = stars_count + 1 WHERE id = $1")
+            .bind(repo_id)
+            .execute(pool)
+            .await?;
+        Ok(true)
+    }
+}
+
+pub async fn is_starred(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> {
+    let row: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT user_id FROM repo_stars WHERE repo_id = $1 AND user_id = $2",
+    )
+    .bind(repo_id)
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
+pub async fn toggle_watch(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> {
+    let existing: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT user_id FROM repo_watches WHERE repo_id = $1 AND user_id = $2",
+    )
+    .bind(repo_id)
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?;
+    if existing.is_some() {
+        sqlx::query("DELETE FROM repo_watches WHERE repo_id = $1 AND user_id = $2")
+            .bind(repo_id)
+            .bind(user_id)
+            .execute(pool)
+            .await?;
+        sqlx::query("UPDATE repositories SET watches_count = GREATEST(watches_count - 1, 0) WHERE id = $1")
+            .bind(repo_id)
+            .execute(pool)
+            .await?;
+        Ok(false)
+    } else {
+        sqlx::query("INSERT INTO repo_watches (repo_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
+            .bind(repo_id)
+            .bind(user_id)
+            .execute(pool)
+            .await?;
+        sqlx::query("UPDATE repositories SET watches_count = watches_count + 1 WHERE id = $1")
+            .bind(repo_id)
+            .execute(pool)
+            .await?;
+        Ok(true)
+    }
+}
+
+pub async fn is_watching(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> {
+    let row: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT user_id FROM repo_watches WHERE repo_id = $1 AND user_id = $2",
+    )
+    .bind(repo_id)
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
+/// Public repos the user has starred (private stars are never returned).
+pub async fn list_public_starred_repos(
+    pool: &PgPool,
+    user_id: Uuid,
+    limit: i64,
+) -> Result<Vec<(Repository, String)>> {
+    #[derive(sqlx::FromRow)]
+    struct Row {
+        id: Uuid,
+        owner_id: Uuid,
+        name: String,
+        description: String,
+        visibility: String,
+        default_branch: String,
+        archived: bool,
+        issues_enabled: bool,
+        pulls_enabled: bool,
+        releases_enabled: bool,
+        allow_merge: bool,
+        allow_squash: bool,
+        allow_rebase: bool,
+        default_merge_style: String,
+        protect_default_branch: bool,
+        protect_block_force_push: bool,
+        fork_of_id: Option<Uuid>,
+        stars_count: i32,
+        watches_count: i32,
+        forks_count: i32,
+        created_at: DateTime<Utc>,
+        updated_at: DateTime<Utc>,
+        owner_username: String,
+    }
+
+    let rows: Vec<Row> = sqlx::query_as(
+        r#"
+        SELECT r.*, u.username AS owner_username
+        FROM repo_stars s
+        JOIN repositories r ON r.id = s.repo_id
+        JOIN users u ON u.id = r.owner_id
+        WHERE s.user_id = $1
+          AND r.visibility = 'public'
+        ORDER BY s.created_at DESC
+        LIMIT $2
+        "#,
+    )
+    .bind(user_id)
+    .bind(limit)
+    .fetch_all(pool)
+    .await?;
+
+    Ok(rows
+        .into_iter()
+        .map(|r| {
+            (
+                Repository {
+                    id: r.id,
+                    owner_id: r.owner_id,
+                    name: r.name,
+                    description: r.description,
+                    visibility: r.visibility,
+                    default_branch: r.default_branch,
+                    archived: r.archived,
+                    issues_enabled: r.issues_enabled,
+                    pulls_enabled: r.pulls_enabled,
+                    releases_enabled: r.releases_enabled,
+                    allow_merge: r.allow_merge,
+                    allow_squash: r.allow_squash,
+                    allow_rebase: r.allow_rebase,
+                    default_merge_style: r.default_merge_style,
+                    protect_default_branch: r.protect_default_branch,
+                    protect_block_force_push: r.protect_block_force_push,
+                    fork_of_id: r.fork_of_id,
+                    stars_count: r.stars_count,
+                    watches_count: r.watches_count,
+                    forks_count: r.forks_count,
+                    created_at: r.created_at,
+                    updated_at: r.updated_at,
+                },
+                r.owner_username,
+            )
+        })
+        .collect())
+}
+
+/// Recent activity on public repos the user is watching.
+pub async fn latest_activity_for_watched_repos(
+    pool: &PgPool,
+    user_id: Uuid,
+    limit: i64,
+) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> {
+    let events = sqlx::query_as::<_, ActivityEvent>(
+        r#"
+        SELECT ae.*
+        FROM activity_events ae
+        JOIN repo_watches w ON w.repo_id = ae.repo_id AND w.user_id = $1
+        JOIN repositories r ON r.id = ae.repo_id AND r.visibility = 'public'
+        ORDER BY ae.created_at DESC
+        LIMIT $2
+        "#,
+    )
+    .bind(user_id)
+    .bind(limit)
+    .fetch_all(pool)
+    .await?;
+    hydrate_activity(pool, events).await
+}
+
+pub async fn create_fork(
+    pool: &PgPool,
+    owner_id: Uuid,
+    name: &str,
+    description: &str,
+    visibility: &str,
+    fork_of_id: Uuid,
+) -> Result<Repository> {
+    let mut tx = pool.begin().await?;
+    let repo = sqlx::query_as::<_, Repository>(
+        r#"
+        INSERT INTO repositories (owner_id, name, description, visibility, fork_of_id)
+        VALUES ($1, $2, $3, $4, $5)
+        RETURNING *
+        "#,
+    )
+    .bind(owner_id)
+    .bind(name)
+    .bind(description)
+    .bind(visibility)
+    .bind(fork_of_id)
+    .fetch_one(&mut *tx)
+    .await?;
+    sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
+        .bind(repo.id)
+        .execute(&mut *tx)
+        .await?;
+    sqlx::query("INSERT INTO language_stats (repo_id) VALUES ($1)")
+        .bind(repo.id)
+        .execute(&mut *tx)
+        .await?;
+    sqlx::query("UPDATE repositories SET forks_count = forks_count + 1 WHERE id = $1")
+        .bind(fork_of_id)
+        .execute(&mut *tx)
+        .await?;
+    tx.commit().await?;
+    Ok(repo)
+}
+
+pub async fn get_fork_of_user(pool: &PgPool, fork_of_id: Uuid, owner_id: Uuid) -> Result<Option<Repository>> {
+    Ok(sqlx::query_as::<_, Repository>(
+        "SELECT * FROM repositories WHERE fork_of_id = $1 AND owner_id = $2",
+    )
+    .bind(fork_of_id)
+    .bind(owner_id)
+    .fetch_optional(pool)
+    .await?)
+}
+
+// ── reactions ────────────────────────────────────────────────────────────────
+
+pub async fn toggle_reaction(
+    pool: &PgPool,
+    comment_id: Uuid,
+    user_id: Uuid,
+    emoji: &str,
+) -> Result<()> {
+    let existing: Option<(Uuid,)> = sqlx::query_as(
+        "SELECT user_id FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND emoji = $3",
+    )
+    .bind(comment_id)
+    .bind(user_id)
+    .bind(emoji)
+    .fetch_optional(pool)
+    .await?;
+    if existing.is_some() {
+        sqlx::query(
+            "DELETE FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND emoji = $3",
+        )
+        .bind(comment_id)
+        .bind(user_id)
+        .bind(emoji)
+        .execute(pool)
+        .await?;
+    } else {
+        sqlx::query(
+            "INSERT INTO comment_reactions (comment_id, user_id, emoji) VALUES ($1, $2, $3)",
+        )
+        .bind(comment_id)
+        .bind(user_id)
+        .bind(emoji)
+        .execute(pool)
+        .await?;
+    }
+    Ok(())
+}
+
+pub async fn list_reaction_counts(
+    pool: &PgPool,
+    comment_id: Uuid,
+    viewer: Option<Uuid>,
+) -> Result<Vec<(String, i64, bool)>> {
+    #[derive(sqlx::FromRow)]
+    struct Row {
+        emoji: String,
+        count: i64,
+        mine: bool,
+    }
+    let rows: Vec<Row> = sqlx::query_as(
+        r#"
+        SELECT emoji,
+               COUNT(*)::bigint AS count,
+               BOOL_OR(user_id = $2) AS mine
+        FROM comment_reactions
+        WHERE comment_id = $1
+        GROUP BY emoji
+        ORDER BY emoji
+        "#,
+    )
+    .bind(comment_id)
+    .bind(viewer)
+    .fetch_all(pool)
+    .await?;
+    Ok(rows.into_iter().map(|r| (r.emoji, r.count, r.mine)).collect())
+}
+
+// ── branch rules ─────────────────────────────────────────────────────────────
+
+pub async fn list_branch_rules(pool: &PgPool, repo_id: Uuid) -> Result<Vec<BranchRule>> {
+    Ok(sqlx::query_as::<_, BranchRule>(
+        "SELECT * FROM branch_rules WHERE repo_id = $1 ORDER BY pattern",
+    )
+    .bind(repo_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn add_branch_rule(
+    pool: &PgPool,
+    repo_id: Uuid,
+    pattern: &str,
+    require_pr: bool,
+    block_force_push: bool,
+    allow_deletions: bool,
+) -> Result<BranchRule> {
+    Ok(sqlx::query_as::<_, BranchRule>(
+        r#"
+        INSERT INTO branch_rules (repo_id, pattern, require_pr, block_force_push, allow_deletions)
+        VALUES ($1, $2, $3, $4, $5)
+        RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(pattern)
+    .bind(require_pr)
+    .bind(block_force_push)
+    .bind(allow_deletions)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn delete_branch_rule(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM branch_rules WHERE id = $1 AND repo_id = $2")
+        .bind(id)
+        .bind(repo_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+// ── user emails / privacy / sessions / gpg ───────────────────────────────────
+
+pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<User> {
+    Ok(sqlx::query_as::<_, User>(
+        "UPDATE users SET username = $2, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(username)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn update_privacy(
+    pool: &PgPool,
+    id: Uuid,
+    show_email: bool,
+    vigilant_mode: bool,
+) -> Result<User> {
+    Ok(sqlx::query_as::<_, User>(
+        r#"
+        UPDATE users SET show_email = $2, vigilant_mode = $3, updated_at = now()
+        WHERE id = $1 RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(show_email)
+    .bind(vigilant_mode)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_user_emails(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserEmail>> {
+    Ok(sqlx::query_as::<_, UserEmail>(
+        "SELECT * FROM user_emails WHERE user_id = $1 ORDER BY is_primary DESC, email",
+    )
+    .bind(user_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn add_user_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<UserEmail> {
+    Ok(sqlx::query_as::<_, UserEmail>(
+        r#"
+        INSERT INTO user_emails (user_id, email, verified, is_primary)
+        VALUES ($1, $2, false, false)
+        RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(email)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn delete_user_email(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM user_emails WHERE id = $1 AND user_id = $2 AND is_primary = false")
+        .bind(id)
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn ensure_primary_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<()> {
+    if email.trim().is_empty() {
+        return Ok(());
+    }
+    sqlx::query(
+        r#"
+        INSERT INTO user_emails (user_id, email, verified, is_primary)
+        VALUES ($1, $2, true, true)
+        ON CONFLICT (user_id, email) DO UPDATE SET is_primary = true, verified = true
+        "#,
+    )
+    .bind(user_id)
+    .bind(email.trim())
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn list_sessions(pool: &PgPool, user_id: Uuid) -> Result<Vec<Session>> {
+    Ok(sqlx::query_as::<_, Session>(
+        "SELECT * FROM sessions WHERE user_id = $1 AND expires_at > now() ORDER BY last_seen_at DESC",
+    )
+    .bind(user_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn delete_session_by_id(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM sessions WHERE id = $1 AND user_id = $2")
+        .bind(id)
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn delete_other_sessions(pool: &PgPool, user_id: Uuid, keep_hash: &str) -> Result<()> {
+    sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND token_hash <> $2")
+        .bind(user_id)
+        .bind(keep_hash)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn list_gpg_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<GpgKey>> {
+    Ok(sqlx::query_as::<_, GpgKey>(
+        "SELECT * FROM gpg_keys WHERE user_id = $1 ORDER BY created_at DESC",
+    )
+    .bind(user_id)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn add_gpg_key(
+    pool: &PgPool,
+    user_id: Uuid,
+    name: &str,
+    public_key: &str,
+    fingerprint: &str,
+) -> Result<GpgKey> {
+    Ok(sqlx::query_as::<_, GpgKey>(
+        r#"
+        INSERT INTO gpg_keys (user_id, name, public_key, fingerprint)
+        VALUES ($1, $2, $3, $4) RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(name)
+    .bind(public_key)
+    .bind(fingerprint)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn delete_gpg_key(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM gpg_keys WHERE id = $1 AND user_id = $2")
+        .bind(id)
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn delete_user(pool: &PgPool, id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM users WHERE id = $1")
+        .bind(id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn get_user_mfa(pool: &PgPool, user_id: Uuid) -> Result<Option<UserMfa>> {
+    Ok(sqlx::query_as::<_, UserMfa>("SELECT * FROM user_mfa WHERE user_id = $1")
+        .bind(user_id)
+        .fetch_optional(pool)
+        .await?)
+}
+
+pub async fn mfa_is_enabled(pool: &PgPool, user_id: Uuid) -> Result<bool> {
+    let row: Option<(bool,)> = sqlx::query_as(
+        "SELECT enabled FROM user_mfa WHERE user_id = $1 AND enabled = TRUE AND totp_secret IS NOT NULL",
+    )
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
+pub async fn mfa_start_enroll(pool: &PgPool, user_id: Uuid, pending_secret: &str) -> Result<UserMfa> {
+    Ok(sqlx::query_as::<_, UserMfa>(
+        r#"
+        INSERT INTO user_mfa (user_id, pending_secret, enabled, totp_secret, recovery_code_hashes, updated_at)
+        VALUES ($1, $2, FALSE, NULL, '{}', now())
+        ON CONFLICT (user_id) DO UPDATE SET
+            pending_secret = EXCLUDED.pending_secret,
+            updated_at = now()
+        RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(pending_secret)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn mfa_confirm_enroll(
+    pool: &PgPool,
+    user_id: Uuid,
+    totp_secret: &str,
+    recovery_hashes: &[String],
+) -> Result<UserMfa> {
+    Ok(sqlx::query_as::<_, UserMfa>(
+        r#"
+        UPDATE user_mfa SET
+            totp_secret = $2,
+            pending_secret = NULL,
+            enabled = TRUE,
+            recovery_code_hashes = $3,
+            updated_at = now()
+        WHERE user_id = $1
+        RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(totp_secret)
+    .bind(recovery_hashes)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn mfa_disable(pool: &PgPool, user_id: Uuid) -> Result<()> {
+    sqlx::query(
+        r#"
+        UPDATE user_mfa SET
+            enabled = FALSE,
+            totp_secret = NULL,
+            pending_secret = NULL,
+            recovery_code_hashes = '{}',
+            updated_at = now()
+        WHERE user_id = $1
+        "#,
+    )
+    .bind(user_id)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn mfa_set_recovery_hashes(
+    pool: &PgPool,
+    user_id: Uuid,
+    hashes: &[String],
+) -> Result<()> {
+    sqlx::query(
+        "UPDATE user_mfa SET recovery_code_hashes = $2, updated_at = now() WHERE user_id = $1",
+    )
+    .bind(user_id)
+    .bind(hashes)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn create_mfa_pending_login(
+    pool: &PgPool,
+    user_id: Uuid,
+    token_hash: &str,
+    minutes: i64,
+) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO mfa_pending_logins (user_id, token_hash, expires_at)
+        VALUES ($1, $2, now() + ($3::text || ' minutes')::interval)
+        "#,
+    )
+    .bind(user_id)
+    .bind(token_hash)
+    .bind(minutes)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn take_mfa_pending_login(pool: &PgPool, token_hash: &str) -> Result<Option<Uuid>> {
+    let row: Option<(Uuid,)> = sqlx::query_as(
+        r#"
+        DELETE FROM mfa_pending_logins
+        WHERE token_hash = $1 AND expires_at > now()
+        RETURNING user_id
+        "#,
+    )
+    .bind(token_hash)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.map(|r| r.0))
+}
+
+pub async fn delete_mfa_pending_for_user(pool: &PgPool, user_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM mfa_pending_logins WHERE user_id = $1")
+        .bind(user_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json::Value> {
+    let user = get_user_by_id(pool, user_id).await?.ok_or_else(|| anyhow::anyhow!("missing"))?;
+    let emails = list_user_emails(pool, user_id).await?;
+    let keys = sqlx::query_as::<_, SshKey>("SELECT * FROM ssh_keys WHERE user_id = $1")
+        .bind(user_id)
+        .fetch_all(pool)
+        .await?;
+    let repos = sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE owner_id = $1")
+        .bind(user_id)
+        .fetch_all(pool)
+        .await?;
+    Ok(serde_json::json!({
+        "user": {
+            "id": user.id,
+            "username": user.username,
+            "display_name": user.display_name,
+            "email": user.email,
+            "bio": user.bio,
+            "show_email": user.show_email,
+            "vigilant_mode": user.vigilant_mode,
+            "created_at": user.created_at,
+        },
+        "emails": emails,
+        "ssh_keys": keys.iter().map(|k| serde_json::json!({
+            "name": k.name,
+            "fingerprint": k.fingerprint,
+            "created_at": k.created_at,
+        })).collect::<Vec<_>>(),
+        "repositories": repos.iter().map(|r| serde_json::json!({
+            "name": r.name,
+            "description": r.description,
+            "visibility": r.visibility,
+            "created_at": r.created_at,
+        })).collect::<Vec<_>>(),
+    }))
+}
+
+pub async fn open_pull_for_branch(
+    pool: &PgPool,
+    repo_id: Uuid,
+    source_branch: &str,
+) -> Result<Option<i32>> {
+    let row: Option<(i32,)> = sqlx::query_as(
+        r#"
+        SELECT number FROM pull_requests
+        WHERE repo_id = $1 AND source_branch = $2 AND state = 'open'
+        ORDER BY number DESC LIMIT 1
+        "#,
+    )
+    .bind(repo_id)
+    .bind(source_branch)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.map(|r| r.0))
+}
+
+pub async fn register_lfs_object(pool: &PgPool, repo_id: Uuid, oid: &str, size: i64) -> Result<()> {
+    sqlx::query(
+        r#"
+        INSERT INTO lfs_objects (oid, size_bytes) VALUES ($1, $2)
+        ON CONFLICT (oid) DO UPDATE SET size_bytes = EXCLUDED.size_bytes
+        "#,
+    )
+    .bind(oid)
+    .bind(size)
+    .execute(pool)
+    .await?;
+    sqlx::query(
+        r#"
+        INSERT INTO lfs_repo_objects (repo_id, oid) VALUES ($1, $2)
+        ON CONFLICT DO NOTHING
+        "#,
+    )
+    .bind(repo_id)
+    .bind(oid)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
+    let row: Option<(i64,)> = sqlx::query_as("SELECT size_bytes FROM lfs_objects WHERE oid = $1")
+        .bind(oid)
+        .fetch_optional(pool)
+        .await?;
+    Ok(row.map(|r| r.0))
+}
diff --git a/src/git/github-colors.json b/src/git/github-colors.json
new file mode 100644
index 0000000..10c76e7
--- /dev/null
+++ b/src/git/github-colors.json
@@ -0,0 +1,2938 @@
+{
+    "1C Enterprise": {
+        "color": "#814CCC",
+        "url": "https://github.com/trending?l=1C-Enterprise"
+    },
+    "2-Dimensional Array": {
+        "color": "#38761D",
+        "url": "https://github.com/trending?l=2-Dimensional-Array"
+    },
+    "4D": {
+        "color": "#004289",
+        "url": "https://github.com/trending?l=4D"
+    },
+    "ABAP": {
+        "color": "#E8274B",
+        "url": "https://github.com/trending?l=ABAP"
+    },
+    "ABAP CDS": {
+        "color": "#555e25",
+        "url": "https://github.com/trending?l=ABAP-CDS"
+    },
+    "ActionScript": {
+        "color": "#882B0F",
+        "url": "https://github.com/trending?l=ActionScript"
+    },
+    "Ada": {
+        "color": "#02f88c",
+        "url": "https://github.com/trending?l=Ada"
+    },
+    "Adblock Filter List": {
+        "color": "#800000",
+        "url": "https://github.com/trending?l=Adblock-Filter-List"
+    },
+    "Adobe Font Metrics": {
+        "color": "#fa0f00",
+        "url": "https://github.com/trending?l=Adobe-Font-Metrics"
+    },
+    "Agda": {
+        "color": "#315665",
+        "url": "https://github.com/trending?l=Agda"
+    },
+    "AGS Script": {
+        "color": "#B9D9FF",
+        "url": "https://github.com/trending?l=AGS-Script"
+    },
+    "AIDL": {
+        "color": "#34EB6B",
+        "url": "https://github.com/trending?l=AIDL"
+    },
+    "Aiken": {
+        "color": "#640ff8",
+        "url": "https://github.com/trending?l=Aiken"
+    },
+    "AL": {
+        "color": "#3AA2B5",
+        "url": "https://github.com/trending?l=AL"
+    },
+    "ALGOL": {
+        "color": "#D1E0DB",
+        "url": "https://github.com/trending?l=ALGOL"
+    },
+    "Alloy": {
+        "color": "#64C800",
+        "url": "https://github.com/trending?l=Alloy"
+    },
+    "Alpine Abuild": {
+        "color": "#0D597F",
+        "url": "https://github.com/trending?l=Alpine-Abuild"
+    },
+    "Altium Designer": {
+        "color": "#A89663",
+        "url": "https://github.com/trending?l=Altium-Designer"
+    },
+    "AMPL": {
+        "color": "#E6EFBB",
+        "url": "https://github.com/trending?l=AMPL"
+    },
+    "AngelScript": {
+        "color": "#C7D7DC",
+        "url": "https://github.com/trending?l=AngelScript"
+    },
+    "Answer Set Programming": {
+        "color": "#A9CC29",
+        "url": "https://github.com/trending?l=Answer-Set-Programming"
+    },
+    "Ant Build System": {
+        "color": "#A9157E",
+        "url": "https://github.com/trending?l=Ant-Build-System"
+    },
+    "Antlers": {
+        "color": "#ff269e",
+        "url": "https://github.com/trending?l=Antlers"
+    },
+    "ANTLR": {
+        "color": "#9DC3FF",
+        "url": "https://github.com/trending?l=ANTLR"
+    },
+    "ApacheConf": {
+        "color": "#d12127",
+        "url": "https://github.com/trending?l=ApacheConf"
+    },
+    "Apex": {
+        "color": "#1797c0",
+        "url": "https://github.com/trending?l=Apex"
+    },
+    "API Blueprint": {
+        "color": "#2ACCA8",
+        "url": "https://github.com/trending?l=API-Blueprint"
+    },
+    "APL": {
+        "color": "#5A8164",
+        "url": "https://github.com/trending?l=APL"
+    },
+    "Apollo Guidance Computer": {
+        "color": "#0B3D91",
+        "url": "https://github.com/trending?l=Apollo-Guidance-Computer"
+    },
+    "AppleScript": {
+        "color": "#101F1F",
+        "url": "https://github.com/trending?l=AppleScript"
+    },
+    "Arc": {
+        "color": "#aa2afe",
+        "url": "https://github.com/trending?l=Arc"
+    },
+    "AsciiDoc": {
+        "color": "#73a0c5",
+        "url": "https://github.com/trending?l=AsciiDoc"
+    },
+    "ASL": {
+        "color": null,
+        "url": "https://github.com/trending?l=ASL"
+    },
+    "ASP.NET": {
+        "color": "#9400ff",
+        "url": "https://github.com/trending?l=ASP.NET"
+    },
+    "AspectJ": {
+        "color": "#a957b0",
+        "url": "https://github.com/trending?l=AspectJ"
+    },
+    "Assembly": {
+        "color": "#6E4C13",
+        "url": "https://github.com/trending?l=Assembly"
+    },
+    "Astro": {
+        "color": "#ff5a03",
+        "url": "https://github.com/trending?l=Astro"
+    },
+    "Asymptote": {
+        "color": "#ff0000",
+        "url": "https://github.com/trending?l=Asymptote"
+    },
+    "ATS": {
+        "color": "#1ac620",
+        "url": "https://github.com/trending?l=ATS"
+    },
+    "Augeas": {
+        "color": "#9CC134",
+        "url": "https://github.com/trending?l=Augeas"
+    },
+    "AutoHotkey": {
+        "color": "#6594b9",
+        "url": "https://github.com/trending?l=AutoHotkey"
+    },
+    "AutoIt": {
+        "color": "#1C3552",
+        "url": "https://github.com/trending?l=AutoIt"
+    },
+    "Avro IDL": {
+        "color": "#0040FF",
+        "url": "https://github.com/trending?l=Avro-IDL"
+    },
+    "Awk": {
+        "color": "#c30e9b",
+        "url": "https://github.com/trending?l=Awk"
+    },
+    "B (Formal Method)": {
+        "color": "#8aa8c5",
+        "url": "https://github.com/trending?l=B-(Formal-Method)"
+    },
+    "B4X": {
+        "color": "#00e4ff",
+        "url": "https://github.com/trending?l=B4X"
+    },
+    "Ballerina": {
+        "color": "#FF5000",
+        "url": "https://github.com/trending?l=Ballerina"
+    },
+    "BAML": {
+        "color": "#a855f7",
+        "url": "https://github.com/trending?l=BAML"
+    },
+    "BASIC": {
+        "color": "#ff0000",
+        "url": "https://github.com/trending?l=BASIC"
+    },
+    "Batchfile": {
+        "color": "#C1F12E",
+        "url": "https://github.com/trending?l=Batchfile"
+    },
+    "Beef": {
+        "color": "#a52f4e",
+        "url": "https://github.com/trending?l=Beef"
+    },
+    "Befunge": {
+        "color": null,
+        "url": "https://github.com/trending?l=Befunge"
+    },
+    "Berry": {
+        "color": "#15A13C",
+        "url": "https://github.com/trending?l=Berry"
+    },
+    "BibTeX": {
+        "color": "#778899",
+        "url": "https://github.com/trending?l=BibTeX"
+    },
+    "BibTeX Style": {
+        "color": null,
+        "url": "https://github.com/trending?l=BibTeX-Style"
+    },
+    "Bicep": {
+        "color": "#519aba",
+        "url": "https://github.com/trending?l=Bicep"
+    },
+    "Bikeshed": {
+        "color": "#5562ac",
+        "url": "https://github.com/trending?l=Bikeshed"
+    },
+    "Bison": {
+        "color": "#6A463F",
+        "url": "https://github.com/trending?l=Bison"
+    },
+    "BitBake": {
+        "color": "#00bce4",
+        "url": "https://github.com/trending?l=BitBake"
+    },
+    "Blade": {
+        "color": "#f7523f",
+        "url": "https://github.com/trending?l=Blade"
+    },
+    "BlitzBasic": {
+        "color": "#00FFAE",
+        "url": "https://github.com/trending?l=BlitzBasic"
+    },
+    "BlitzMax": {
+        "color": "#cd6400",
+        "url": "https://github.com/trending?l=BlitzMax"
+    },
+    "Blueprint": {
+        "color": "#3584E4",
+        "url": "https://github.com/trending?l=Blueprint"
+    },
+    "Bluespec": {
+        "color": "#12223c",
+        "url": "https://github.com/trending?l=Bluespec"
+    },
+    "Bluespec BH": {
+        "color": "#12223c",
+        "url": "https://github.com/trending?l=Bluespec-BH"
+    },
+    "Boo": {
+        "color": "#d4bec1",
+        "url": "https://github.com/trending?l=Boo"
+    },
+    "Boogie": {
+        "color": "#c80fa0",
+        "url": "https://github.com/trending?l=Boogie"
+    },
+    "BQN": {
+        "color": "#2b7067",
+        "url": "https://github.com/trending?l=BQN"
+    },
+    "Brainfuck": {
+        "color": "#2F2530",
+        "url": "https://github.com/trending?l=Brainfuck"
+    },
+    "BrighterScript": {
+        "color": "#66AABB",
+        "url": "https://github.com/trending?l=BrighterScript"
+    },
+    "Brightscript": {
+        "color": "#662D91",
+        "url": "https://github.com/trending?l=Brightscript"
+    },
+    "Browserslist": {
+        "color": "#ffd539",
+        "url": "https://github.com/trending?l=Browserslist"
+    },
+    "Bru": {
+        "color": "#F4AA41",
+        "url": "https://github.com/trending?l=Bru"
+    },
+    "BuildStream": {
+        "color": "#006bff",
+        "url": "https://github.com/trending?l=BuildStream"
+    },
+    "C": {
+        "color": "#555555",
+        "url": "https://github.com/trending?l=C"
+    },
+    "C#": {
+        "color": "#7355dd",
+        "url": "https://github.com/trending?l=Csharp"
+    },
+    "C++": {
+        "color": "#f34b7d",
+        "url": "https://github.com/trending?l=C++"
+    },
+    "C2hs Haskell": {
+        "color": null,
+        "url": "https://github.com/trending?l=C2hs-Haskell"
+    },
+    "C3": {
+        "color": "#2563eb",
+        "url": "https://github.com/trending?l=C3"
+    },
+    "Cabal Config": {
+        "color": "#483465",
+        "url": "https://github.com/trending?l=Cabal-Config"
+    },
+    "Caddyfile": {
+        "color": "#22b638",
+        "url": "https://github.com/trending?l=Caddyfile"
+    },
+    "Cadence": {
+        "color": "#00ef8b",
+        "url": "https://github.com/trending?l=Cadence"
+    },
+    "Cairo": {
+        "color": "#ff4a48",
+        "url": "https://github.com/trending?l=Cairo"
+    },
+    "Cairo Zero": {
+        "color": "#ff4a48",
+        "url": "https://github.com/trending?l=Cairo-Zero"
+    },
+    "CameLIGO": {
+        "color": "#3be133",
+        "url": "https://github.com/trending?l=CameLIGO"
+    },
+    "Cangjie": {
+        "color": "#00868B",
+        "url": "https://github.com/trending?l=Cangjie"
+    },
+    "CAP CDS": {
+        "color": "#0092d1",
+        "url": "https://github.com/trending?l=CAP-CDS"
+    },
+    "Cap'n Proto": {
+        "color": "#c42727",
+        "url": "https://github.com/trending?l=Cap'n-Proto"
+    },
+    "Carbon": {
+        "color": "#222222",
+        "url": "https://github.com/trending?l=Carbon"
+    },
+    "CartoCSS": {
+        "color": null,
+        "url": "https://github.com/trending?l=CartoCSS"
+    },
+    "Ceylon": {
+        "color": "#dfa535",
+        "url": "https://github.com/trending?l=Ceylon"
+    },
+    "Chapel": {
+        "color": "#8dc63f",
+        "url": "https://github.com/trending?l=Chapel"
+    },
+    "Charity": {
+        "color": null,
+        "url": "https://github.com/trending?l=Charity"
+    },
+    "ChucK": {
+        "color": "#3f8000",
+        "url": "https://github.com/trending?l=ChucK"
+    },
+    "Circom": {
+        "color": "#707575",
+        "url": "https://github.com/trending?l=Circom"
+    },
+    "Cirru": {
+        "color": "#ccccff",
+        "url": "https://github.com/trending?l=Cirru"
+    },
+    "Clarion": {
+        "color": "#db901e",
+        "url": "https://github.com/trending?l=Clarion"
+    },
+    "Clarity": {
+        "color": "#5546ff",
+        "url": "https://github.com/trending?l=Clarity"
+    },
+    "Classic ASP": {
+        "color": "#6a40fd",
+        "url": "https://github.com/trending?l=Classic-ASP"
+    },
+    "Clean": {
+        "color": "#3F85AF",
+        "url": "https://github.com/trending?l=Clean"
+    },
+    "Click": {
+        "color": "#E4E6F3",
+        "url": "https://github.com/trending?l=Click"
+    },
+    "CLIPS": {
+        "color": "#00A300",
+        "url": "https://github.com/trending?l=CLIPS"
+    },
+    "Clojure": {
+        "color": "#db5855",
+        "url": "https://github.com/trending?l=Clojure"
+    },
+    "Closure Templates": {
+        "color": "#0d948f",
+        "url": "https://github.com/trending?l=Closure-Templates"
+    },
+    "Cloud Firestore Security Rules": {
+        "color": "#FFA000",
+        "url": "https://github.com/trending?l=Cloud-Firestore-Security-Rules"
+    },
+    "Clue": {
+        "color": "#0009b5",
+        "url": "https://github.com/trending?l=Clue"
+    },
+    "CMake": {
+        "color": "#DA3434",
+        "url": "https://github.com/trending?l=CMake"
+    },
+    "COBOL": {
+        "color": null,
+        "url": "https://github.com/trending?l=COBOL"
+    },
+    "CodeQL": {
+        "color": "#140f46",
+        "url": "https://github.com/trending?l=CodeQL"
+    },
+    "CoffeeScript": {
+        "color": "#244776",
+        "url": "https://github.com/trending?l=CoffeeScript"
+    },
+    "ColdFusion": {
+        "color": "#ed2cd6",
+        "url": "https://github.com/trending?l=ColdFusion"
+    },
+    "ColdFusion CFC": {
+        "color": "#ed2cd6",
+        "url": "https://github.com/trending?l=ColdFusion-CFC"
+    },
+    "COLLADA": {
+        "color": "#F1A42B",
+        "url": "https://github.com/trending?l=COLLADA"
+    },
+    "Common Lisp": {
+        "color": "#3fb68b",
+        "url": "https://github.com/trending?l=Common-Lisp"
+    },
+    "Common Workflow Language": {
+        "color": "#B5314C",
+        "url": "https://github.com/trending?l=Common-Workflow-Language"
+    },
+    "Component Pascal": {
+        "color": "#B0CE4E",
+        "url": "https://github.com/trending?l=Component-Pascal"
+    },
+    "Cooklang": {
+        "color": "#E15A29",
+        "url": "https://github.com/trending?l=Cooklang"
+    },
+    "Cool": {
+        "color": null,
+        "url": "https://github.com/trending?l=Cool"
+    },
+    "CQL": {
+        "color": "#006091",
+        "url": "https://github.com/trending?l=CQL"
+    },
+    "crontab": {
+        "color": "#ead7ac",
+        "url": "https://github.com/trending?l=crontab"
+    },
+    "Crystal": {
+        "color": "#000100",
+        "url": "https://github.com/trending?l=Crystal"
+    },
+    "CSON": {
+        "color": "#244776",
+        "url": "https://github.com/trending?l=CSON"
+    },
+    "Csound": {
+        "color": "#1a1a1a",
+        "url": "https://github.com/trending?l=Csound"
+    },
+    "Csound Document": {
+        "color": "#1a1a1a",
+        "url": "https://github.com/trending?l=Csound-Document"
+    },
+    "Csound Score": {
+        "color": "#1a1a1a",
+        "url": "https://github.com/trending?l=Csound-Score"
+    },
+    "CSS": {
+        "color": "#663399",
+        "url": "https://github.com/trending?l=CSS"
+    },
+    "CSV": {
+        "color": "#237346",
+        "url": "https://github.com/trending?l=CSV"
+    },
+    "Cuda": {
+        "color": "#3A4E3A",
+        "url": "https://github.com/trending?l=Cuda"
+    },
+    "CUE": {
+        "color": "#5886E1",
+        "url": "https://github.com/trending?l=CUE"
+    },
+    "Curry": {
+        "color": "#531242",
+        "url": "https://github.com/trending?l=Curry"
+    },
+    "CWeb": {
+        "color": "#00007a",
+        "url": "https://github.com/trending?l=CWeb"
+    },
+    "Cycript": {
+        "color": null,
+        "url": "https://github.com/trending?l=Cycript"
+    },
+    "Cylc": {
+        "color": "#00b3fd",
+        "url": "https://github.com/trending?l=Cylc"
+    },
+    "Cypher": {
+        "color": "#34c0eb",
+        "url": "https://github.com/trending?l=Cypher"
+    },
+    "Cython": {
+        "color": "#fedf5b",
+        "url": "https://github.com/trending?l=Cython"
+    },
+    "D": {
+        "color": "#ba595e",
+        "url": "https://github.com/trending?l=D"
+    },
+    "D2": {
+        "color": "#526ee8",
+        "url": "https://github.com/trending?l=D2"
+    },
+    "Dafny": {
+        "color": "#FFEC25",
+        "url": "https://github.com/trending?l=Dafny"
+    },
+    "Darcs Patch": {
+        "color": "#8eff23",
+        "url": "https://github.com/trending?l=Darcs-Patch"
+    },
+    "Dart": {
+        "color": "#00B4AB",
+        "url": "https://github.com/trending?l=Dart"
+    },
+    "Daslang": {
+        "color": "#d3d3d3",
+        "url": "https://github.com/trending?l=Daslang"
+    },
+    "DataWeave": {
+        "color": "#003a52",
+        "url": "https://github.com/trending?l=DataWeave"
+    },
+    "Debian Package Control File": {
+        "color": "#D70751",
+        "url": "https://github.com/trending?l=Debian-Package-Control-File"
+    },
+    "DenizenScript": {
+        "color": "#FBEE96",
+        "url": "https://github.com/trending?l=DenizenScript"
+    },
+    "Dhall": {
+        "color": "#dfafff",
+        "url": "https://github.com/trending?l=Dhall"
+    },
+    "DIGITAL Command Language": {
+        "color": null,
+        "url": "https://github.com/trending?l=DIGITAL-Command-Language"
+    },
+    "DirectX 3D File": {
+        "color": "#aace60",
+        "url": "https://github.com/trending?l=DirectX-3D-File"
+    },
+    "DM": {
+        "color": "#447265",
+        "url": "https://github.com/trending?l=DM"
+    },
+    "Dockerfile": {
+        "color": "#384d54",
+        "url": "https://github.com/trending?l=Dockerfile"
+    },
+    "Dogescript": {
+        "color": "#cca760",
+        "url": "https://github.com/trending?l=Dogescript"
+    },
+    "Dotenv": {
+        "color": "#e5d559",
+        "url": "https://github.com/trending?l=Dotenv"
+    },
+    "DTrace": {
+        "color": null,
+        "url": "https://github.com/trending?l=DTrace"
+    },
+    "Dune": {
+        "color": "#89421e",
+        "url": "https://github.com/trending?l=Dune"
+    },
+    "Dylan": {
+        "color": "#6c616e",
+        "url": "https://github.com/trending?l=Dylan"
+    },
+    "E": {
+        "color": "#ccce35",
+        "url": "https://github.com/trending?l=E"
+    },
+    "Earthly": {
+        "color": "#2af0ff",
+        "url": "https://github.com/trending?l=Earthly"
+    },
+    "Easybuild": {
+        "color": "#069406",
+        "url": "https://github.com/trending?l=Easybuild"
+    },
+    "eC": {
+        "color": "#913960",
+        "url": "https://github.com/trending?l=eC"
+    },
+    "Ecere Projects": {
+        "color": "#913960",
+        "url": "https://github.com/trending?l=Ecere-Projects"
+    },
+    "ECL": {
+        "color": "#8a1267",
+        "url": "https://github.com/trending?l=ECL"
+    },
+    "ECLiPSe": {
+        "color": "#001d9d",
+        "url": "https://github.com/trending?l=ECLiPSe"
+    },
+    "Ecmarkup": {
+        "color": "#eb8131",
+        "url": "https://github.com/trending?l=Ecmarkup"
+    },
+    "Edge": {
+        "color": "#0dffe0",
+        "url": "https://github.com/trending?l=Edge"
+    },
+    "EdgeQL": {
+        "color": "#31A7FF",
+        "url": "https://github.com/trending?l=EdgeQL"
+    },
+    "EditorConfig": {
+        "color": "#fff1f2",
+        "url": "https://github.com/trending?l=EditorConfig"
+    },
+    "Eiffel": {
+        "color": "#4d6977",
+        "url": "https://github.com/trending?l=Eiffel"
+    },
+    "EJS": {
+        "color": "#a91e50",
+        "url": "https://github.com/trending?l=EJS"
+    },
+    "Elixir": {
+        "color": "#6e4a7e",
+        "url": "https://github.com/trending?l=Elixir"
+    },
+    "Elm": {
+        "color": "#60B5CC",
+        "url": "https://github.com/trending?l=Elm"
+    },
+    "Elvish": {
+        "color": "#55BB55",
+        "url": "https://github.com/trending?l=Elvish"
+    },
+    "Elvish Transcript": {
+        "color": "#55BB55",
+        "url": "https://github.com/trending?l=Elvish-Transcript"
+    },
+    "Emacs Lisp": {
+        "color": "#c065db",
+        "url": "https://github.com/trending?l=Emacs-Lisp"
+    },
+    "EmberScript": {
+        "color": "#FFF4F3",
+        "url": "https://github.com/trending?l=EmberScript"
+    },
+    "EQ": {
+        "color": "#a78649",
+        "url": "https://github.com/trending?l=EQ"
+    },
+    "Erlang": {
+        "color": "#B83998",
+        "url": "https://github.com/trending?l=Erlang"
+    },
+    "Euphoria": {
+        "color": "#FF790B",
+        "url": "https://github.com/trending?l=Euphoria"
+    },
+    "F#": {
+        "color": "#b845fc",
+        "url": "https://github.com/trending?l=Fsharp"
+    },
+    "F*": {
+        "color": "#572e30",
+        "url": "https://github.com/trending?l=F*"
+    },
+    "Factor": {
+        "color": "#636746",
+        "url": "https://github.com/trending?l=Factor"
+    },
+    "Fancy": {
+        "color": "#7b9db4",
+        "url": "https://github.com/trending?l=Fancy"
+    },
+    "Fantom": {
+        "color": "#14253c",
+        "url": "https://github.com/trending?l=Fantom"
+    },
+    "Faust": {
+        "color": "#c37240",
+        "url": "https://github.com/trending?l=Faust"
+    },
+    "Fennel": {
+        "color": "#fff3d7",
+        "url": "https://github.com/trending?l=Fennel"
+    },
+    "FIGlet Font": {
+        "color": "#FFDDBB",
+        "url": "https://github.com/trending?l=FIGlet-Font"
+    },
+    "Filebench WML": {
+        "color": "#F6B900",
+        "url": "https://github.com/trending?l=Filebench-WML"
+    },
+    "Filterscript": {
+        "color": null,
+        "url": "https://github.com/trending?l=Filterscript"
+    },
+    "FIRRTL": {
+        "color": "#2f632f",
+        "url": "https://github.com/trending?l=FIRRTL"
+    },
+    "fish": {
+        "color": "#4aae47",
+        "url": "https://github.com/trending?l=fish"
+    },
+    "FlatBuffers": {
+        "color": "#ed284a",
+        "url": "https://github.com/trending?l=FlatBuffers"
+    },
+    "Flix": {
+        "color": "#d44a45",
+        "url": "https://github.com/trending?l=Flix"
+    },
+    "Fluent": {
+        "color": "#ffcc33",
+        "url": "https://github.com/trending?l=Fluent"
+    },
+    "FLUX": {
+        "color": "#88ccff",
+        "url": "https://github.com/trending?l=FLUX"
+    },
+    "Forth": {
+        "color": "#341708",
+        "url": "https://github.com/trending?l=Forth"
+    },
+    "Fortran": {
+        "color": "#4d41b1",
+        "url": "https://github.com/trending?l=Fortran"
+    },
+    "Fortran Free Form": {
+        "color": "#4d41b1",
+        "url": "https://github.com/trending?l=Fortran-Free-Form"
+    },
+    "FreeBASIC": {
+        "color": "#141AC9",
+        "url": "https://github.com/trending?l=FreeBASIC"
+    },
+    "FreeMarker": {
+        "color": "#0050b2",
+        "url": "https://github.com/trending?l=FreeMarker"
+    },
+    "Frege": {
+        "color": "#00cafe",
+        "url": "https://github.com/trending?l=Frege"
+    },
+    "Futhark": {
+        "color": "#5f021f",
+        "url": "https://github.com/trending?l=Futhark"
+    },
+    "G-code": {
+        "color": "#D08CF2",
+        "url": "https://github.com/trending?l=G-code"
+    },
+    "Game Maker Language": {
+        "color": "#71b417",
+        "url": "https://github.com/trending?l=Game-Maker-Language"
+    },
+    "GAML": {
+        "color": "#FFC766",
+        "url": "https://github.com/trending?l=GAML"
+    },
+    "GAMS": {
+        "color": "#f49a22",
+        "url": "https://github.com/trending?l=GAMS"
+    },
+    "GAP": {
+        "color": "#0000cc",
+        "url": "https://github.com/trending?l=GAP"
+    },
+    "GCC Machine Description": {
+        "color": "#FFCFAB",
+        "url": "https://github.com/trending?l=GCC-Machine-Description"
+    },
+    "GDB": {
+        "color": null,
+        "url": "https://github.com/trending?l=GDB"
+    },
+    "GDScript": {
+        "color": "#355570",
+        "url": "https://github.com/trending?l=GDScript"
+    },
+    "GDShader": {
+        "color": "#478CBF",
+        "url": "https://github.com/trending?l=GDShader"
+    },
+    "GEDCOM": {
+        "color": "#003058",
+        "url": "https://github.com/trending?l=GEDCOM"
+    },
+    "Gemfile.lock": {
+        "color": "#701516",
+        "url": "https://github.com/trending?l=Gemfile.lock"
+    },
+    "Gemini": {
+        "color": "#ff6900",
+        "url": "https://github.com/trending?l=Gemini"
+    },
+    "Genero 4gl": {
+        "color": "#63408e",
+        "url": "https://github.com/trending?l=Genero-4gl"
+    },
+    "Genero per": {
+        "color": "#d8df39",
+        "url": "https://github.com/trending?l=Genero-per"
+    },
+    "Genie": {
+        "color": "#fb855d",
+        "url": "https://github.com/trending?l=Genie"
+    },
+    "Genshi": {
+        "color": "#951531",
+        "url": "https://github.com/trending?l=Genshi"
+    },
+    "Gentoo Ebuild": {
+        "color": "#9400ff",
+        "url": "https://github.com/trending?l=Gentoo-Ebuild"
+    },
+    "Gentoo Eclass": {
+        "color": "#9400ff",
+        "url": "https://github.com/trending?l=Gentoo-Eclass"
+    },
+    "Gerber Image": {
+        "color": "#d20b00",
+        "url": "https://github.com/trending?l=Gerber-Image"
+    },
+    "Gherkin": {
+        "color": "#5B2063",
+        "url": "https://github.com/trending?l=Gherkin"
+    },
+    "Git Attributes": {
+        "color": "#F44D27",
+        "url": "https://github.com/trending?l=Git-Attributes"
+    },
+    "Git Commit": {
+        "color": "#F44D27",
+        "url": "https://github.com/trending?l=Git-Commit"
+    },
+    "Git Config": {
+        "color": "#F44D27",
+        "url": "https://github.com/trending?l=Git-Config"
+    },
+    "Git Revision List": {
+        "color": "#F44D27",
+        "url": "https://github.com/trending?l=Git-Revision-List"
+    },
+    "Gleam": {
+        "color": "#ffaff3",
+        "url": "https://github.com/trending?l=Gleam"
+    },
+    "Glimmer JS": {
+        "color": "#F5835F",
+        "url": "https://github.com/trending?l=Glimmer-JS"
+    },
+    "Glimmer TS": {
+        "color": "#3178c6",
+        "url": "https://github.com/trending?l=Glimmer-TS"
+    },
+    "GLSL": {
+        "color": "#5686a5",
+        "url": "https://github.com/trending?l=GLSL"
+    },
+    "Glyph": {
+        "color": "#c1ac7f",
+        "url": "https://github.com/trending?l=Glyph"
+    },
+    "Gno": {
+        "color": "#226c57",
+        "url": "https://github.com/trending?l=Gno"
+    },
+    "Gnuplot": {
+        "color": "#f0a9f0",
+        "url": "https://github.com/trending?l=Gnuplot"
+    },
+    "Go": {
+        "color": "#00ADD8",
+        "url": "https://github.com/trending?l=Go"
+    },
+    "Go Checksums": {
+        "color": "#00ADD8",
+        "url": "https://github.com/trending?l=Go-Checksums"
+    },
+    "Go Module": {
+        "color": "#00ADD8",
+        "url": "https://github.com/trending?l=Go-Module"
+    },
+    "Go Template": {
+        "color": "#00ADD8",
+        "url": "https://github.com/trending?l=Go-Template"
+    },
+    "Go Workspace": {
+        "color": "#00ADD8",
+        "url": "https://github.com/trending?l=Go-Workspace"
+    },
+    "Godot Resource": {
+        "color": "#355570",
+        "url": "https://github.com/trending?l=Godot-Resource"
+    },
+    "Golo": {
+        "color": "#88562A",
+        "url": "https://github.com/trending?l=Golo"
+    },
+    "Gosu": {
+        "color": "#82937f",
+        "url": "https://github.com/trending?l=Gosu"
+    },
+    "Grace": {
+        "color": "#615f8b",
+        "url": "https://github.com/trending?l=Grace"
+    },
+    "Gradle": {
+        "color": "#02303a",
+        "url": "https://github.com/trending?l=Gradle"
+    },
+    "Gradle Kotlin DSL": {
+        "color": "#02303a",
+        "url": "https://github.com/trending?l=Gradle-Kotlin-DSL"
+    },
+    "Grammatical Framework": {
+        "color": "#ff0000",
+        "url": "https://github.com/trending?l=Grammatical-Framework"
+    },
+    "GraphQL": {
+        "color": "#e10098",
+        "url": "https://github.com/trending?l=GraphQL"
+    },
+    "Graphviz (DOT)": {
+        "color": "#2596be",
+        "url": "https://github.com/trending?l=Graphviz-(DOT)"
+    },
+    "Groovy": {
+        "color": "#4298b8",
+        "url": "https://github.com/trending?l=Groovy"
+    },
+    "Groovy Server Pages": {
+        "color": "#4298b8",
+        "url": "https://github.com/trending?l=Groovy-Server-Pages"
+    },
+    "GSC": {
+        "color": "#FF6800",
+        "url": "https://github.com/trending?l=GSC"
+    },
+    "GtkRC": {
+        "color": "#7fe719",
+        "url": "https://github.com/trending?l=GtkRC"
+    },
+    "Hack": {
+        "color": "#878787",
+        "url": "https://github.com/trending?l=Hack"
+    },
+    "Haml": {
+        "color": "#ece2a9",
+        "url": "https://github.com/trending?l=Haml"
+    },
+    "Handlebars": {
+        "color": "#f7931e",
+        "url": "https://github.com/trending?l=Handlebars"
+    },
+    "HAProxy": {
+        "color": "#106da9",
+        "url": "https://github.com/trending?l=HAProxy"
+    },
+    "Harbour": {
+        "color": "#0e60e3",
+        "url": "https://github.com/trending?l=Harbour"
+    },
+    "Hare": {
+        "color": "#9d7424",
+        "url": "https://github.com/trending?l=Hare"
+    },
+    "Haskell": {
+        "color": "#5e5086",
+        "url": "https://github.com/trending?l=Haskell"
+    },
+    "Haxe": {
+        "color": "#df7900",
+        "url": "https://github.com/trending?l=Haxe"
+    },
+    "HCL": {
+        "color": "#844FBA",
+        "url": "https://github.com/trending?l=HCL"
+    },
+    "HIP": {
+        "color": "#4F3A4F",
+        "url": "https://github.com/trending?l=HIP"
+    },
+    "HiveQL": {
+        "color": "#dce200",
+        "url": "https://github.com/trending?l=HiveQL"
+    },
+    "HLSL": {
+        "color": "#aace60",
+        "url": "https://github.com/trending?l=HLSL"
+    },
+    "HOCON": {
+        "color": "#9ff8ee",
+        "url": "https://github.com/trending?l=HOCON"
+    },
+    "HolyC": {
+        "color": "#ffefaf",
+        "url": "https://github.com/trending?l=HolyC"
+    },
+    "hoon": {
+        "color": "#00b171",
+        "url": "https://github.com/trending?l=hoon"
+    },
+    "Hosts File": {
+        "color": "#308888",
+        "url": "https://github.com/trending?l=Hosts-File"
+    },
+    "HTML": {
+        "color": "#e34c26",
+        "url": "https://github.com/trending?l=HTML"
+    },
+    "HTML+ECR": {
+        "color": "#2e1052",
+        "url": "https://github.com/trending?l=HTML+ECR"
+    },
+    "HTML+EEX": {
+        "color": "#6e4a7e",
+        "url": "https://github.com/trending?l=HTML+EEX"
+    },
+    "HTML+ERB": {
+        "color": "#701516",
+        "url": "https://github.com/trending?l=HTML+ERB"
+    },
+    "HTML+PHP": {
+        "color": "#4f5d95",
+        "url": "https://github.com/trending?l=HTML+PHP"
+    },
+    "HTML+Razor": {
+        "color": "#512be4",
+        "url": "https://github.com/trending?l=HTML+Razor"
+    },
+    "HTTP": {
+        "color": "#005C9C",
+        "url": "https://github.com/trending?l=HTTP"
+    },
+    "Hurl": {
+        "color": "#FF0288",
+        "url": "https://github.com/trending?l=Hurl"
+    },
+    "HXML": {
+        "color": "#f68712",
+        "url": "https://github.com/trending?l=HXML"
+    },
+    "Hy": {
+        "color": "#7790B2",
+        "url": "https://github.com/trending?l=Hy"
+    },
+    "HyPhy": {
+        "color": null,
+        "url": "https://github.com/trending?l=HyPhy"
+    },
+    "iCalendar": {
+        "color": "#ec564c",
+        "url": "https://github.com/trending?l=iCalendar"
+    },
+    "IDL": {
+        "color": "#a3522f",
+        "url": "https://github.com/trending?l=IDL"
+    },
+    "Idris": {
+        "color": "#b30000",
+        "url": "https://github.com/trending?l=Idris"
+    },
+    "Ignore List": {
+        "color": "#000000",
+        "url": "https://github.com/trending?l=Ignore-List"
+    },
+    "IGOR Pro": {
+        "color": "#0000cc",
+        "url": "https://github.com/trending?l=IGOR-Pro"
+    },
+    "IL Assembly": {
+        "color": "#512BD4",
+        "url": "https://github.com/trending?l=IL-Assembly"
+    },
+    "ImageJ Macro": {
+        "color": "#99AAFF",
+        "url": "https://github.com/trending?l=ImageJ-Macro"
+    },
+    "Imba": {
+        "color": "#16cec6",
+        "url": "https://github.com/trending?l=Imba"
+    },
+    "Inform 7": {
+        "color": null,
+        "url": "https://github.com/trending?l=Inform-7"
+    },
+    "INI": {
+        "color": "#d1dbe0",
+        "url": "https://github.com/trending?l=INI"
+    },
+    "Ink": {
+        "color": null,
+        "url": "https://github.com/trending?l=Ink"
+    },
+    "Inno Setup": {
+        "color": "#264b99",
+        "url": "https://github.com/trending?l=Inno-Setup"
+    },
+    "Io": {
+        "color": "#a9188d",
+        "url": "https://github.com/trending?l=Io"
+    },
+    "Ioke": {
+        "color": "#078193",
+        "url": "https://github.com/trending?l=Ioke"
+    },
+    "Isabelle": {
+        "color": "#FEFE00",
+        "url": "https://github.com/trending?l=Isabelle"
+    },
+    "Isabelle ROOT": {
+        "color": "#FEFE00",
+        "url": "https://github.com/trending?l=Isabelle-ROOT"
+    },
+    "ISPC": {
+        "color": "#2D68B1",
+        "url": "https://github.com/trending?l=ISPC"
+    },
+    "J": {
+        "color": "#9EEDFF",
+        "url": "https://github.com/trending?l=J"
+    },
+    "Jac": {
+        "color": "#FC792D",
+        "url": "https://github.com/trending?l=Jac"
+    },
+    "Jai": {
+        "color": "#ab8b4b",
+        "url": "https://github.com/trending?l=Jai"
+    },
+    "Janet": {
+        "color": "#0886a5",
+        "url": "https://github.com/trending?l=Janet"
+    },
+    "JAR Manifest": {
+        "color": "#b07219",
+        "url": "https://github.com/trending?l=JAR-Manifest"
+    },
+    "Jasmin": {
+        "color": "#d03600",
+        "url": "https://github.com/trending?l=Jasmin"
+    },
+    "Java": {
+        "color": "#b07219",
+        "url": "https://github.com/trending?l=Java"
+    },
+    "Java Properties": {
+        "color": "#2A6277",
+        "url": "https://github.com/trending?l=Java-Properties"
+    },
+    "Java Server Pages": {
+        "color": "#2A6277",
+        "url": "https://github.com/trending?l=Java-Server-Pages"
+    },
+    "Java Template Engine": {
+        "color": "#2A6277",
+        "url": "https://github.com/trending?l=Java-Template-Engine"
+    },
+    "JavaScript": {
+        "color": "#f1e05a",
+        "url": "https://github.com/trending?l=JavaScript"
+    },
+    "JavaScript+ERB": {
+        "color": "#f1e05a",
+        "url": "https://github.com/trending?l=JavaScript+ERB"
+    },
+    "JCL": {
+        "color": "#d90e09",
+        "url": "https://github.com/trending?l=JCL"
+    },
+    "Jest Snapshot": {
+        "color": "#15c213",
+        "url": "https://github.com/trending?l=Jest-Snapshot"
+    },
+    "JetBrains MPS": {
+        "color": "#21D789",
+        "url": "https://github.com/trending?l=JetBrains-MPS"
+    },
+    "JFlex": {
+        "color": "#DBCA00",
+        "url": "https://github.com/trending?l=JFlex"
+    },
+    "Jinja": {
+        "color": "#a52a22",
+        "url": "https://github.com/trending?l=Jinja"
+    },
+    "Jison": {
+        "color": "#56b3cb",
+        "url": "https://github.com/trending?l=Jison"
+    },
+    "Jison Lex": {
+        "color": "#56b3cb",
+        "url": "https://github.com/trending?l=Jison-Lex"
+    },
+    "Jolie": {
+        "color": "#843179",
+        "url": "https://github.com/trending?l=Jolie"
+    },
+    "jq": {
+        "color": "#c7254e",
+        "url": "https://github.com/trending?l=jq"
+    },
+    "JSON": {
+        "color": "#292929",
+        "url": "https://github.com/trending?l=JSON"
+    },
+    "JSON with Comments": {
+        "color": "#292929",
+        "url": "https://github.com/trending?l=JSON-with-Comments"
+    },
+    "JSON5": {
+        "color": "#267CB9",
+        "url": "https://github.com/trending?l=JSON5"
+    },
+    "JSONiq": {
+        "color": "#40d47e",
+        "url": "https://github.com/trending?l=JSONiq"
+    },
+    "JSONLD": {
+        "color": "#0c479c",
+        "url": "https://github.com/trending?l=JSONLD"
+    },
+    "Jsonnet": {
+        "color": "#0064bd",
+        "url": "https://github.com/trending?l=Jsonnet"
+    },
+    "Julia": {
+        "color": "#a270ba",
+        "url": "https://github.com/trending?l=Julia"
+    },
+    "Julia REPL": {
+        "color": "#a270ba",
+        "url": "https://github.com/trending?l=Julia-REPL"
+    },
+    "Jupyter Notebook": {
+        "color": "#DA5B0B",
+        "url": "https://github.com/trending?l=Jupyter-Notebook"
+    },
+    "Just": {
+        "color": "#384d54",
+        "url": "https://github.com/trending?l=Just"
+    },
+    "Kaitai Struct": {
+        "color": "#773b37",
+        "url": "https://github.com/trending?l=Kaitai-Struct"
+    },
+    "KakouneScript": {
+        "color": "#6f8042",
+        "url": "https://github.com/trending?l=KakouneScript"
+    },
+    "KCL": {
+        "color": "#7ABABF",
+        "url": "https://github.com/trending?l=KCL"
+    },
+    "KDL": {
+        "color": "#ffb3b3",
+        "url": "https://github.com/trending?l=KDL"
+    },
+    "KerboScript": {
+        "color": "#41adf0",
+        "url": "https://github.com/trending?l=KerboScript"
+    },
+    "KFramework": {
+        "color": "#4195c5",
+        "url": "https://github.com/trending?l=KFramework"
+    },
+    "KiCad Layout": {
+        "color": "#2f4aab",
+        "url": "https://github.com/trending?l=KiCad-Layout"
+    },
+    "KiCad Legacy Layout": {
+        "color": "#2f4aab",
+        "url": "https://github.com/trending?l=KiCad-Legacy-Layout"
+    },
+    "KiCad Schematic": {
+        "color": "#2f4aab",
+        "url": "https://github.com/trending?l=KiCad-Schematic"
+    },
+    "Koka": {
+        "color": "#215166",
+        "url": "https://github.com/trending?l=Koka"
+    },
+    "KoLmafia ASH": {
+        "color": "#B9D9B9",
+        "url": "https://github.com/trending?l=KoLmafia-ASH"
+    },
+    "Kotlin": {
+        "color": "#A97BFF",
+        "url": "https://github.com/trending?l=Kotlin"
+    },
+    "KRL": {
+        "color": "#28430A",
+        "url": "https://github.com/trending?l=KRL"
+    },
+    "kvlang": {
+        "color": "#1da6e0",
+        "url": "https://github.com/trending?l=kvlang"
+    },
+    "LabVIEW": {
+        "color": "#fede06",
+        "url": "https://github.com/trending?l=LabVIEW"
+    },
+    "Lambdapi": {
+        "color": "#8027a3",
+        "url": "https://github.com/trending?l=Lambdapi"
+    },
+    "Langium": {
+        "color": "#2c8c87",
+        "url": "https://github.com/trending?l=Langium"
+    },
+    "Lark": {
+        "color": "#2980B9",
+        "url": "https://github.com/trending?l=Lark"
+    },
+    "Lasso": {
+        "color": "#999999",
+        "url": "https://github.com/trending?l=Lasso"
+    },
+    "Latte": {
+        "color": "#f2a542",
+        "url": "https://github.com/trending?l=Latte"
+    },
+    "Lean": {
+        "color": null,
+        "url": "https://github.com/trending?l=Lean"
+    },
+    "Lean 4": {
+        "color": null,
+        "url": "https://github.com/trending?l=Lean-4"
+    },
+    "Leo": {
+        "color": "#C4FFC2",
+        "url": "https://github.com/trending?l=Leo"
+    },
+    "Less": {
+        "color": "#1d365d",
+        "url": "https://github.com/trending?l=Less"
+    },
+    "Lex": {
+        "color": "#DBCA00",
+        "url": "https://github.com/trending?l=Lex"
+    },
+    "LFE": {
+        "color": "#4C3023",
+        "url": "https://github.com/trending?l=LFE"
+    },
+    "LigoLANG": {
+        "color": "#0e74ff",
+        "url": "https://github.com/trending?l=LigoLANG"
+    },
+    "LilyPond": {
+        "color": "#9ccc7c",
+        "url": "https://github.com/trending?l=LilyPond"
+    },
+    "Limbo": {
+        "color": null,
+        "url": "https://github.com/trending?l=Limbo"
+    },
+    "Linear Programming": {
+        "color": null,
+        "url": "https://github.com/trending?l=Linear-Programming"
+    },
+    "Linker Script": {
+        "color": null,
+        "url": "https://github.com/trending?l=Linker-Script"
+    },
+    "Liquid": {
+        "color": "#67b8de",
+        "url": "https://github.com/trending?l=Liquid"
+    },
+    "Liquidsoap": {
+        "color": "#990066",
+        "url": "https://github.com/trending?l=Liquidsoap"
+    },
+    "Literate Agda": {
+        "color": "#315665",
+        "url": "https://github.com/trending?l=Literate-Agda"
+    },
+    "Literate CoffeeScript": {
+        "color": "#244776",
+        "url": "https://github.com/trending?l=Literate-CoffeeScript"
+    },
+    "Literate Haskell": {
+        "color": "#5e5086",
+        "url": "https://github.com/trending?l=Literate-Haskell"
+    },
+    "LiveCode Script": {
+        "color": "#0c5ba5",
+        "url": "https://github.com/trending?l=LiveCode-Script"
+    },
+    "LiveScript": {
+        "color": "#499886",
+        "url": "https://github.com/trending?l=LiveScript"
+    },
+    "LLVM": {
+        "color": "#185619",
+        "url": "https://github.com/trending?l=LLVM"
+    },
+    "Logos": {
+        "color": null,
+        "url": "https://github.com/trending?l=Logos"
+    },
+    "Logtalk": {
+        "color": "#295b9a",
+        "url": "https://github.com/trending?l=Logtalk"
+    },
+    "LOLCODE": {
+        "color": "#cc9900",
+        "url": "https://github.com/trending?l=LOLCODE"
+    },
+    "LookML": {
+        "color": "#652B81",
+        "url": "https://github.com/trending?l=LookML"
+    },
+    "LoomScript": {
+        "color": null,
+        "url": "https://github.com/trending?l=LoomScript"
+    },
+    "LSL": {
+        "color": "#3d9970",
+        "url": "https://github.com/trending?l=LSL"
+    },
+    "Lua": {
+        "color": "#000080",
+        "url": "https://github.com/trending?l=Lua"
+    },
+    "Luau": {
+        "color": "#00A2FF",
+        "url": "https://github.com/trending?l=Luau"
+    },
+    "M": {
+        "color": null,
+        "url": "https://github.com/trending?l=M"
+    },
+    "M3U": {
+        "color": "#179C7D",
+        "url": "https://github.com/trending?l=M3U"
+    },
+    "M4": {
+        "color": null,
+        "url": "https://github.com/trending?l=M4"
+    },
+    "M4Sugar": {
+        "color": null,
+        "url": "https://github.com/trending?l=M4Sugar"
+    },
+    "Macaulay2": {
+        "color": "#d8ffff",
+        "url": "https://github.com/trending?l=Macaulay2"
+    },
+    "Makefile": {
+        "color": "#427819",
+        "url": "https://github.com/trending?l=Makefile"
+    },
+    "Mako": {
+        "color": "#7e858d",
+        "url": "https://github.com/trending?l=Mako"
+    },
+    "Markdown": {
+        "color": "#083fa1",
+        "url": "https://github.com/trending?l=Markdown"
+    },
+    "Marko": {
+        "color": "#42bff2",
+        "url": "https://github.com/trending?l=Marko"
+    },
+    "Mask": {
+        "color": "#f97732",
+        "url": "https://github.com/trending?l=Mask"
+    },
+    "Mathematical Programming System": {
+        "color": "#0530ad",
+        "url": "https://github.com/trending?l=Mathematical-Programming-System"
+    },
+    "MATLAB": {
+        "color": "#e16737",
+        "url": "https://github.com/trending?l=MATLAB"
+    },
+    "Max": {
+        "color": "#c4a79c",
+        "url": "https://github.com/trending?l=Max"
+    },
+    "MAXScript": {
+        "color": "#00a6a6",
+        "url": "https://github.com/trending?l=MAXScript"
+    },
+    "mcfunction": {
+        "color": "#E22837",
+        "url": "https://github.com/trending?l=mcfunction"
+    },
+    "mdsvex": {
+        "color": "#5f9ea0",
+        "url": "https://github.com/trending?l=mdsvex"
+    },
+    "MDX": {
+        "color": "#fcb32c",
+        "url": "https://github.com/trending?l=MDX"
+    },
+    "Mercury": {
+        "color": "#ff2b2b",
+        "url": "https://github.com/trending?l=Mercury"
+    },
+    "Mermaid": {
+        "color": "#ff3670",
+        "url": "https://github.com/trending?l=Mermaid"
+    },
+    "Meson": {
+        "color": "#007800",
+        "url": "https://github.com/trending?l=Meson"
+    },
+    "Metal": {
+        "color": "#8f14e9",
+        "url": "https://github.com/trending?l=Metal"
+    },
+    "MeTTa": {
+        "color": "#6a5acd",
+        "url": "https://github.com/trending?l=MeTTa"
+    },
+    "MiniD": {
+        "color": null,
+        "url": "https://github.com/trending?l=MiniD"
+    },
+    "MiniYAML": {
+        "color": "#ff1111",
+        "url": "https://github.com/trending?l=MiniYAML"
+    },
+    "MiniZinc": {
+        "color": "#06a9e6",
+        "url": "https://github.com/trending?l=MiniZinc"
+    },
+    "Mint": {
+        "color": "#02b046",
+        "url": "https://github.com/trending?l=Mint"
+    },
+    "Mirah": {
+        "color": "#c7a938",
+        "url": "https://github.com/trending?l=Mirah"
+    },
+    "mIRC Script": {
+        "color": "#3d57c3",
+        "url": "https://github.com/trending?l=mIRC-Script"
+    },
+    "MLIR": {
+        "color": "#5EC8DB",
+        "url": "https://github.com/trending?l=MLIR"
+    },
+    "Modelica": {
+        "color": "#de1d31",
+        "url": "https://github.com/trending?l=Modelica"
+    },
+    "Modula-2": {
+        "color": "#10253f",
+        "url": "https://github.com/trending?l=Modula-2"
+    },
+    "Modula-3": {
+        "color": "#223388",
+        "url": "https://github.com/trending?l=Modula-3"
+    },
+    "Module Management System": {
+        "color": null,
+        "url": "https://github.com/trending?l=Module-Management-System"
+    },
+    "Mojo": {
+        "color": "#ff4c1f",
+        "url": "https://github.com/trending?l=Mojo"
+    },
+    "Monkey": {
+        "color": null,
+        "url": "https://github.com/trending?l=Monkey"
+    },
+    "Monkey C": {
+        "color": "#8D6747",
+        "url": "https://github.com/trending?l=Monkey-C"
+    },
+    "Moocode": {
+        "color": null,
+        "url": "https://github.com/trending?l=Moocode"
+    },
+    "MoonBit": {
+        "color": "#b92381",
+        "url": "https://github.com/trending?l=MoonBit"
+    },
+    "MoonScript": {
+        "color": "#ff4585",
+        "url": "https://github.com/trending?l=MoonScript"
+    },
+    "Motoko": {
+        "color": "#fbb03b",
+        "url": "https://github.com/trending?l=Motoko"
+    },
+    "Motorola 68K Assembly": {
+        "color": "#005daa",
+        "url": "https://github.com/trending?l=Motorola-68K-Assembly"
+    },
+    "Move": {
+        "color": "#4a137a",
+        "url": "https://github.com/trending?l=Move"
+    },
+    "MQL4": {
+        "color": "#62A8D6",
+        "url": "https://github.com/trending?l=MQL4"
+    },
+    "MQL5": {
+        "color": "#4A76B8",
+        "url": "https://github.com/trending?l=MQL5"
+    },
+    "MTML": {
+        "color": "#b7e1f4",
+        "url": "https://github.com/trending?l=MTML"
+    },
+    "MUF": {
+        "color": null,
+        "url": "https://github.com/trending?l=MUF"
+    },
+    "mupad": {
+        "color": "#244963",
+        "url": "https://github.com/trending?l=mupad"
+    },
+    "Mustache": {
+        "color": "#724b3b",
+        "url": "https://github.com/trending?l=Mustache"
+    },
+    "Myghty": {
+        "color": null,
+        "url": "https://github.com/trending?l=Myghty"
+    },
+    "nanorc": {
+        "color": "#2d004d",
+        "url": "https://github.com/trending?l=nanorc"
+    },
+    "Nasal": {
+        "color": "#1d2c4e",
+        "url": "https://github.com/trending?l=Nasal"
+    },
+    "NASL": {
+        "color": null,
+        "url": "https://github.com/trending?l=NASL"
+    },
+    "NCL": {
+        "color": "#28431f",
+        "url": "https://github.com/trending?l=NCL"
+    },
+    "Nearley": {
+        "color": "#990000",
+        "url": "https://github.com/trending?l=Nearley"
+    },
+    "Nemerle": {
+        "color": "#3d3c6e",
+        "url": "https://github.com/trending?l=Nemerle"
+    },
+    "nesC": {
+        "color": "#94B0C7",
+        "url": "https://github.com/trending?l=nesC"
+    },
+    "NetLinx": {
+        "color": "#0aa0ff",
+        "url": "https://github.com/trending?l=NetLinx"
+    },
+    "NetLinx+ERB": {
+        "color": "#747faa",
+        "url": "https://github.com/trending?l=NetLinx+ERB"
+    },
+    "NetLogo": {
+        "color": "#ff6375",
+        "url": "https://github.com/trending?l=NetLogo"
+    },
+    "NewLisp": {
+        "color": "#87AED7",
+        "url": "https://github.com/trending?l=NewLisp"
+    },
+    "Nextflow": {
+        "color": "#3ac486",
+        "url": "https://github.com/trending?l=Nextflow"
+    },
+    "Nginx": {
+        "color": "#009639",
+        "url": "https://github.com/trending?l=Nginx"
+    },
+    "Nickel": {
+        "color": "#E0C3FC",
+        "url": "https://github.com/trending?l=Nickel"
+    },
+    "Nim": {
+        "color": "#ffc200",
+        "url": "https://github.com/trending?l=Nim"
+    },
+    "Nit": {
+        "color": "#009917",
+        "url": "https://github.com/trending?l=Nit"
+    },
+    "Nix": {
+        "color": "#7e7eff",
+        "url": "https://github.com/trending?l=Nix"
+    },
+    "NMODL": {
+        "color": "#00356B",
+        "url": "https://github.com/trending?l=NMODL"
+    },
+    "Noir": {
+        "color": "#2f1f49",
+        "url": "https://github.com/trending?l=Noir"
+    },
+    "NPM Config": {
+        "color": "#cb3837",
+        "url": "https://github.com/trending?l=NPM-Config"
+    },
+    "NSIS": {
+        "color": null,
+        "url": "https://github.com/trending?l=NSIS"
+    },
+    "Nu": {
+        "color": "#c9df40",
+        "url": "https://github.com/trending?l=Nu"
+    },
+    "NumPy": {
+        "color": "#9C8AF9",
+        "url": "https://github.com/trending?l=NumPy"
+    },
+    "Nunjucks": {
+        "color": "#3d8137",
+        "url": "https://github.com/trending?l=Nunjucks"
+    },
+    "Nushell": {
+        "color": "#4E9906",
+        "url": "https://github.com/trending?l=Nushell"
+    },
+    "NWScript": {
+        "color": "#111522",
+        "url": "https://github.com/trending?l=NWScript"
+    },
+    "OASv2-json": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OASv2-json"
+    },
+    "OASv2-yaml": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OASv2-yaml"
+    },
+    "OASv3-json": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OASv3-json"
+    },
+    "OASv3-yaml": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OASv3-yaml"
+    },
+    "Oberon": {
+        "color": null,
+        "url": "https://github.com/trending?l=Oberon"
+    },
+    "Objective-C": {
+        "color": "#438eff",
+        "url": "https://github.com/trending?l=Objective-C"
+    },
+    "Objective-C++": {
+        "color": "#6866fb",
+        "url": "https://github.com/trending?l=Objective-C++"
+    },
+    "Objective-J": {
+        "color": "#ff0c5a",
+        "url": "https://github.com/trending?l=Objective-J"
+    },
+    "ObjectScript": {
+        "color": "#424893",
+        "url": "https://github.com/trending?l=ObjectScript"
+    },
+    "OCaml": {
+        "color": "#ef7a08",
+        "url": "https://github.com/trending?l=OCaml"
+    },
+    "Odin": {
+        "color": "#60AFFE",
+        "url": "https://github.com/trending?l=Odin"
+    },
+    "Omgrofl": {
+        "color": "#cabbff",
+        "url": "https://github.com/trending?l=Omgrofl"
+    },
+    "OMNeT++ MSG": {
+        "color": "#a0e0a0",
+        "url": "https://github.com/trending?l=OMNeT++-MSG"
+    },
+    "OMNeT++ NED": {
+        "color": "#08607c",
+        "url": "https://github.com/trending?l=OMNeT++-NED"
+    },
+    "ooc": {
+        "color": "#b0b77e",
+        "url": "https://github.com/trending?l=ooc"
+    },
+    "Opa": {
+        "color": null,
+        "url": "https://github.com/trending?l=Opa"
+    },
+    "Opal": {
+        "color": "#f7ede0",
+        "url": "https://github.com/trending?l=Opal"
+    },
+    "Open Policy Agent": {
+        "color": "#7d9199",
+        "url": "https://github.com/trending?l=Open-Policy-Agent"
+    },
+    "OpenAPI Specification v2": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OpenAPI-Specification-v2"
+    },
+    "OpenAPI Specification v3": {
+        "color": "#85ea2d",
+        "url": "https://github.com/trending?l=OpenAPI-Specification-v3"
+    },
+    "OpenCL": {
+        "color": "#ed2e2d",
+        "url": "https://github.com/trending?l=OpenCL"
+    },
+    "OpenEdge ABL": {
+        "color": "#5ce600",
+        "url": "https://github.com/trending?l=OpenEdge-ABL"
+    },
+    "OpenQASM": {
+        "color": "#AA70FF",
+        "url": "https://github.com/trending?l=OpenQASM"
+    },
+    "OpenRC runscript": {
+        "color": null,
+        "url": "https://github.com/trending?l=OpenRC-runscript"
+    },
+    "OpenSCAD": {
+        "color": "#e5cd45",
+        "url": "https://github.com/trending?l=OpenSCAD"
+    },
+    "Option List": {
+        "color": "#476732",
+        "url": "https://github.com/trending?l=Option-List"
+    },
+    "Org": {
+        "color": "#77aa99",
+        "url": "https://github.com/trending?l=Org"
+    },
+    "OverpassQL": {
+        "color": "#cce2aa",
+        "url": "https://github.com/trending?l=OverpassQL"
+    },
+    "OverPy": {
+        "color": "#78b355",
+        "url": "https://github.com/trending?l=OverPy"
+    },
+    "Ox": {
+        "color": null,
+        "url": "https://github.com/trending?l=Ox"
+    },
+    "Oxygene": {
+        "color": "#cdd0e3",
+        "url": "https://github.com/trending?l=Oxygene"
+    },
+    "Oz": {
+        "color": "#fab738",
+        "url": "https://github.com/trending?l=Oz"
+    },
+    "P4": {
+        "color": "#7055b5",
+        "url": "https://github.com/trending?l=P4"
+    },
+    "Pact": {
+        "color": "#F7A8B8",
+        "url": "https://github.com/trending?l=Pact"
+    },
+    "Pan": {
+        "color": "#cc0000",
+        "url": "https://github.com/trending?l=Pan"
+    },
+    "Papyrus": {
+        "color": "#6600cc",
+        "url": "https://github.com/trending?l=Papyrus"
+    },
+    "Parrot": {
+        "color": "#f3ca0a",
+        "url": "https://github.com/trending?l=Parrot"
+    },
+    "Parrot Assembly": {
+        "color": null,
+        "url": "https://github.com/trending?l=Parrot-Assembly"
+    },
+    "Parrot Internal Representation": {
+        "color": null,
+        "url": "https://github.com/trending?l=Parrot-Internal-Representation"
+    },
+    "Pascal": {
+        "color": "#E3F171",
+        "url": "https://github.com/trending?l=Pascal"
+    },
+    "Pawn": {
+        "color": "#dbb284",
+        "url": "https://github.com/trending?l=Pawn"
+    },
+    "PDDL": {
+        "color": "#0d00ff",
+        "url": "https://github.com/trending?l=PDDL"
+    },
+    "PEG.js": {
+        "color": "#234d6b",
+        "url": "https://github.com/trending?l=PEG.js"
+    },
+    "Pep8": {
+        "color": "#C76F5B",
+        "url": "https://github.com/trending?l=Pep8"
+    },
+    "Perl": {
+        "color": "#0298c3",
+        "url": "https://github.com/trending?l=Perl"
+    },
+    "PHP": {
+        "color": "#4F5D95",
+        "url": "https://github.com/trending?l=PHP"
+    },
+    "PicoLisp": {
+        "color": "#6067af",
+        "url": "https://github.com/trending?l=PicoLisp"
+    },
+    "PigLatin": {
+        "color": "#fcd7de",
+        "url": "https://github.com/trending?l=PigLatin"
+    },
+    "Pike": {
+        "color": "#005390",
+        "url": "https://github.com/trending?l=Pike"
+    },
+    "Pip Requirements": {
+        "color": "#FFD343",
+        "url": "https://github.com/trending?l=Pip-Requirements"
+    },
+    "pkg-config": {
+        "color": "#2b5e82",
+        "url": "https://github.com/trending?l=pkg-config"
+    },
+    "Pkl": {
+        "color": "#6b9543",
+        "url": "https://github.com/trending?l=Pkl"
+    },
+    "PlantUML": {
+        "color": "#fbbd16",
+        "url": "https://github.com/trending?l=PlantUML"
+    },
+    "PLpgSQL": {
+        "color": "#336790",
+        "url": "https://github.com/trending?l=PLpgSQL"
+    },
+    "PLSQL": {
+        "color": "#dad8d8",
+        "url": "https://github.com/trending?l=PLSQL"
+    },
+    "PogoScript": {
+        "color": "#d80074",
+        "url": "https://github.com/trending?l=PogoScript"
+    },
+    "Polar": {
+        "color": "#ae81ff",
+        "url": "https://github.com/trending?l=Polar"
+    },
+    "Pony": {
+        "color": null,
+        "url": "https://github.com/trending?l=Pony"
+    },
+    "Portugol": {
+        "color": "#f8bd00",
+        "url": "https://github.com/trending?l=Portugol"
+    },
+    "PostCSS": {
+        "color": "#dc3a0c",
+        "url": "https://github.com/trending?l=PostCSS"
+    },
+    "PostScript": {
+        "color": "#da291c",
+        "url": "https://github.com/trending?l=PostScript"
+    },
+    "POV-Ray SDL": {
+        "color": "#6bac65",
+        "url": "https://github.com/trending?l=POV-Ray-SDL"
+    },
+    "Power Query": {
+        "color": "#d38e0d",
+        "url": "https://github.com/trending?l=Power-Query"
+    },
+    "PowerBuilder": {
+        "color": "#8f0f8d",
+        "url": "https://github.com/trending?l=PowerBuilder"
+    },
+    "PowerShell": {
+        "color": "#012456",
+        "url": "https://github.com/trending?l=PowerShell"
+    },
+    "Praat": {
+        "color": "#c8506d",
+        "url": "https://github.com/trending?l=Praat"
+    },
+    "Prisma": {
+        "color": "#0c344b",
+        "url": "https://github.com/trending?l=Prisma"
+    },
+    "Pro*C": {
+        "color": "#bb8368",
+        "url": "https://github.com/trending?l=Pro*C"
+    },
+    "Processing": {
+        "color": "#0096D8",
+        "url": "https://github.com/trending?l=Processing"
+    },
+    "Procfile": {
+        "color": "#3B2F63",
+        "url": "https://github.com/trending?l=Procfile"
+    },
+    "Prolog": {
+        "color": "#74283c",
+        "url": "https://github.com/trending?l=Prolog"
+    },
+    "Promela": {
+        "color": "#de0000",
+        "url": "https://github.com/trending?l=Promela"
+    },
+    "Propeller Spin": {
+        "color": "#7fa2a7",
+        "url": "https://github.com/trending?l=Propeller-Spin"
+    },
+    "Pug": {
+        "color": "#a86454",
+        "url": "https://github.com/trending?l=Pug"
+    },
+    "Puppet": {
+        "color": "#302B6D",
+        "url": "https://github.com/trending?l=Puppet"
+    },
+    "PureBasic": {
+        "color": "#5a6986",
+        "url": "https://github.com/trending?l=PureBasic"
+    },
+    "PureScript": {
+        "color": "#1D222D",
+        "url": "https://github.com/trending?l=PureScript"
+    },
+    "Pyret": {
+        "color": "#ee1e10",
+        "url": "https://github.com/trending?l=Pyret"
+    },
+    "Python": {
+        "color": "#3572A5",
+        "url": "https://github.com/trending?l=Python"
+    },
+    "Python console": {
+        "color": "#3572A5",
+        "url": "https://github.com/trending?l=Python-console"
+    },
+    "Python traceback": {
+        "color": "#3572A5",
+        "url": "https://github.com/trending?l=Python-traceback"
+    },
+    "q": {
+        "color": "#0040cd",
+        "url": "https://github.com/trending?l=q"
+    },
+    "Q#": {
+        "color": "#fed659",
+        "url": "https://github.com/trending?l=Qsharp"
+    },
+    "QMake": {
+        "color": null,
+        "url": "https://github.com/trending?l=QMake"
+    },
+    "QML": {
+        "color": "#44a51c",
+        "url": "https://github.com/trending?l=QML"
+    },
+    "Qt Script": {
+        "color": "#00b841",
+        "url": "https://github.com/trending?l=Qt-Script"
+    },
+    "Quake": {
+        "color": "#882233",
+        "url": "https://github.com/trending?l=Quake"
+    },
+    "QuakeC": {
+        "color": "#975777",
+        "url": "https://github.com/trending?l=QuakeC"
+    },
+    "QuickBASIC": {
+        "color": "#008080",
+        "url": "https://github.com/trending?l=QuickBASIC"
+    },
+    "Quint": {
+        "color": "#9d6ce5",
+        "url": "https://github.com/trending?l=Quint"
+    },
+    "R": {
+        "color": "#198CE7",
+        "url": "https://github.com/trending?l=R"
+    },
+    "Racket": {
+        "color": "#3c5caa",
+        "url": "https://github.com/trending?l=Racket"
+    },
+    "Ragel": {
+        "color": "#9d5200",
+        "url": "https://github.com/trending?l=Ragel"
+    },
+    "Raku": {
+        "color": "#0000fb",
+        "url": "https://github.com/trending?l=Raku"
+    },
+    "RAML": {
+        "color": "#77d9fb",
+        "url": "https://github.com/trending?l=RAML"
+    },
+    "Rascal": {
+        "color": "#fffaa0",
+        "url": "https://github.com/trending?l=Rascal"
+    },
+    "RAScript": {
+        "color": "#2C97FA",
+        "url": "https://github.com/trending?l=RAScript"
+    },
+    "RBS": {
+        "color": "#701516",
+        "url": "https://github.com/trending?l=RBS"
+    },
+    "RDoc": {
+        "color": "#701516",
+        "url": "https://github.com/trending?l=RDoc"
+    },
+    "REALbasic": {
+        "color": null,
+        "url": "https://github.com/trending?l=REALbasic"
+    },
+    "Reason": {
+        "color": "#ff5847",
+        "url": "https://github.com/trending?l=Reason"
+    },
+    "ReasonLIGO": {
+        "color": "#ff5847",
+        "url": "https://github.com/trending?l=ReasonLIGO"
+    },
+    "Rebol": {
+        "color": "#358a5b",
+        "url": "https://github.com/trending?l=Rebol"
+    },
+    "Record Jar": {
+        "color": "#0673ba",
+        "url": "https://github.com/trending?l=Record-Jar"
+    },
+    "Red": {
+        "color": "#f50000",
+        "url": "https://github.com/trending?l=Red"
+    },
+    "Redcode": {
+        "color": null,
+        "url": "https://github.com/trending?l=Redcode"
+    },
+    "Redscript": {
+        "color": "#f44336",
+        "url": "https://github.com/trending?l=Redscript"
+    },
+    "Regular Expression": {
+        "color": "#009a00",
+        "url": "https://github.com/trending?l=Regular-Expression"
+    },
+    "Ren'Py": {
+        "color": "#ff7f7f",
+        "url": "https://github.com/trending?l=Ren'Py"
+    },
+    "RenderScript": {
+        "color": null,
+        "url": "https://github.com/trending?l=RenderScript"
+    },
+    "ReScript": {
+        "color": "#ed5051",
+        "url": "https://github.com/trending?l=ReScript"
+    },
+    "reStructuredText": {
+        "color": "#141414",
+        "url": "https://github.com/trending?l=reStructuredText"
+    },
+    "REXX": {
+        "color": "#d90e09",
+        "url": "https://github.com/trending?l=REXX"
+    },
+    "Rez": {
+        "color": "#FFDAB3",
+        "url": "https://github.com/trending?l=Rez"
+    },
+    "Ring": {
+        "color": "#2D54CB",
+        "url": "https://github.com/trending?l=Ring"
+    },
+    "Riot": {
+        "color": "#A71E49",
+        "url": "https://github.com/trending?l=Riot"
+    },
+    "RMarkdown": {
+        "color": "#198ce7",
+        "url": "https://github.com/trending?l=RMarkdown"
+    },
+    "RobotFramework": {
+        "color": "#00c0b5",
+        "url": "https://github.com/trending?l=RobotFramework"
+    },
+    "Roc": {
+        "color": "#7c38f5",
+        "url": "https://github.com/trending?l=Roc"
+    },
+    "Rocq Prover": {
+        "color": "#d0b68c",
+        "url": "https://github.com/trending?l=Rocq-Prover"
+    },
+    "Roff": {
+        "color": "#ecdebe",
+        "url": "https://github.com/trending?l=Roff"
+    },
+    "Roff Manpage": {
+        "color": "#ecdebe",
+        "url": "https://github.com/trending?l=Roff-Manpage"
+    },
+    "RON": {
+        "color": "#a62c00",
+        "url": "https://github.com/trending?l=RON"
+    },
+    "ROS Interface": {
+        "color": "#22314e",
+        "url": "https://github.com/trending?l=ROS-Interface"
+    },
+    "Rouge": {
+        "color": "#cc0088",
+        "url": "https://github.com/trending?l=Rouge"
+    },
+    "RouterOS Script": {
+        "color": "#DE3941",
+        "url": "https://github.com/trending?l=RouterOS-Script"
+    },
+    "RPC": {
+        "color": null,
+        "url": "https://github.com/trending?l=RPC"
+    },
+    "RPGLE": {
+        "color": "#2BDE21",
+        "url": "https://github.com/trending?l=RPGLE"
+    },
+    "Ruby": {
+        "color": "#701516",
+        "url": "https://github.com/trending?l=Ruby"
+    },
+    "RUNOFF": {
+        "color": "#665a4e",
+        "url": "https://github.com/trending?l=RUNOFF"
+    },
+    "Rust": {
+        "color": "#dea584",
+        "url": "https://github.com/trending?l=Rust"
+    },
+    "Sage": {
+        "color": null,
+        "url": "https://github.com/trending?l=Sage"
+    },
+    "Sail": {
+        "color": "#259dd5",
+        "url": "https://github.com/trending?l=Sail"
+    },
+    "SaltStack": {
+        "color": "#646464",
+        "url": "https://github.com/trending?l=SaltStack"
+    },
+    "SAS": {
+        "color": "#B34936",
+        "url": "https://github.com/trending?l=SAS"
+    },
+    "Sass": {
+        "color": "#a53b70",
+        "url": "https://github.com/trending?l=Sass"
+    },
+    "Scala": {
+        "color": "#c22d40",
+        "url": "https://github.com/trending?l=Scala"
+    },
+    "Scaml": {
+        "color": "#bd181a",
+        "url": "https://github.com/trending?l=Scaml"
+    },
+    "Scenic": {
+        "color": "#fdc700",
+        "url": "https://github.com/trending?l=Scenic"
+    },
+    "Scheme": {
+        "color": "#1e4aec",
+        "url": "https://github.com/trending?l=Scheme"
+    },
+    "Scilab": {
+        "color": "#ca0f21",
+        "url": "https://github.com/trending?l=Scilab"
+    },
+    "SCSS": {
+        "color": "#c6538c",
+        "url": "https://github.com/trending?l=SCSS"
+    },
+    "sed": {
+        "color": "#64b970",
+        "url": "https://github.com/trending?l=sed"
+    },
+    "Self": {
+        "color": "#0579aa",
+        "url": "https://github.com/trending?l=Self"
+    },
+    "ShaderLab": {
+        "color": "#222c37",
+        "url": "https://github.com/trending?l=ShaderLab"
+    },
+    "Shell": {
+        "color": "#89e051",
+        "url": "https://github.com/trending?l=Shell"
+    },
+    "ShellCheck Config": {
+        "color": "#cecfcb",
+        "url": "https://github.com/trending?l=ShellCheck-Config"
+    },
+    "ShellSession": {
+        "color": null,
+        "url": "https://github.com/trending?l=ShellSession"
+    },
+    "Shen": {
+        "color": "#120F14",
+        "url": "https://github.com/trending?l=Shen"
+    },
+    "Sieve": {
+        "color": null,
+        "url": "https://github.com/trending?l=Sieve"
+    },
+    "Simple File Verification": {
+        "color": "#C9BFED",
+        "url": "https://github.com/trending?l=Simple-File-Verification"
+    },
+    "Singularity": {
+        "color": "#64E6AD",
+        "url": "https://github.com/trending?l=Singularity"
+    },
+    "Slang": {
+        "color": "#1fbec9",
+        "url": "https://github.com/trending?l=Slang"
+    },
+    "Slash": {
+        "color": "#007eff",
+        "url": "https://github.com/trending?l=Slash"
+    },
+    "Slice": {
+        "color": "#003fa2",
+        "url": "https://github.com/trending?l=Slice"
+    },
+    "Slim": {
+        "color": "#2b2b2b",
+        "url": "https://github.com/trending?l=Slim"
+    },
+    "Slint": {
+        "color": "#2379F4",
+        "url": "https://github.com/trending?l=Slint"
+    },
+    "Smali": {
+        "color": null,
+        "url": "https://github.com/trending?l=Smali"
+    },
+    "Smalltalk": {
+        "color": "#596706",
+        "url": "https://github.com/trending?l=Smalltalk"
+    },
+    "Smarty": {
+        "color": "#f0c040",
+        "url": "https://github.com/trending?l=Smarty"
+    },
+    "Smithy": {
+        "color": "#c44536",
+        "url": "https://github.com/trending?l=Smithy"
+    },
+    "SmPL": {
+        "color": "#c94949",
+        "url": "https://github.com/trending?l=SmPL"
+    },
+    "SMT": {
+        "color": null,
+        "url": "https://github.com/trending?l=SMT"
+    },
+    "Snakemake": {
+        "color": "#419179",
+        "url": "https://github.com/trending?l=Snakemake"
+    },
+    "Solidity": {
+        "color": "#AA6746",
+        "url": "https://github.com/trending?l=Solidity"
+    },
+    "SourcePawn": {
+        "color": "#f69e1d",
+        "url": "https://github.com/trending?l=SourcePawn"
+    },
+    "SPARQL": {
+        "color": "#0C4597",
+        "url": "https://github.com/trending?l=SPARQL"
+    },
+    "SpiceDB Schema": {
+        "color": "#a5318a",
+        "url": "https://github.com/trending?l=SpiceDB-Schema"
+    },
+    "SQF": {
+        "color": "#3F3F3F",
+        "url": "https://github.com/trending?l=SQF"
+    },
+    "SQL": {
+        "color": "#e38c00",
+        "url": "https://github.com/trending?l=SQL"
+    },
+    "SQLPL": {
+        "color": "#e38c00",
+        "url": "https://github.com/trending?l=SQLPL"
+    },
+    "Squirrel": {
+        "color": "#800000",
+        "url": "https://github.com/trending?l=Squirrel"
+    },
+    "SRecode Template": {
+        "color": "#348a34",
+        "url": "https://github.com/trending?l=SRecode-Template"
+    },
+    "Stan": {
+        "color": "#b2011d",
+        "url": "https://github.com/trending?l=Stan"
+    },
+    "Standard ML": {
+        "color": "#dc566d",
+        "url": "https://github.com/trending?l=Standard-ML"
+    },
+    "Starlark": {
+        "color": "#76d275",
+        "url": "https://github.com/trending?l=Starlark"
+    },
+    "Stata": {
+        "color": "#1a5f91",
+        "url": "https://github.com/trending?l=Stata"
+    },
+    "STL": {
+        "color": "#373b5e",
+        "url": "https://github.com/trending?l=STL"
+    },
+    "StringTemplate": {
+        "color": "#3fb34f",
+        "url": "https://github.com/trending?l=StringTemplate"
+    },
+    "Stylus": {
+        "color": "#ff6347",
+        "url": "https://github.com/trending?l=Stylus"
+    },
+    "SubRip Text": {
+        "color": "#9e0101",
+        "url": "https://github.com/trending?l=SubRip-Text"
+    },
+    "SugarSS": {
+        "color": "#2fcc9f",
+        "url": "https://github.com/trending?l=SugarSS"
+    },
+    "SuperCollider": {
+        "color": "#46390b",
+        "url": "https://github.com/trending?l=SuperCollider"
+    },
+    "SurrealQL": {
+        "color": "#ff00a0",
+        "url": "https://github.com/trending?l=SurrealQL"
+    },
+    "Survex data": {
+        "color": "#ffcc99",
+        "url": "https://github.com/trending?l=Survex-data"
+    },
+    "Svelte": {
+        "color": "#ff3e00",
+        "url": "https://github.com/trending?l=Svelte"
+    },
+    "SVG": {
+        "color": "#ff9900",
+        "url": "https://github.com/trending?l=SVG"
+    },
+    "Sway": {
+        "color": "#00F58C",
+        "url": "https://github.com/trending?l=Sway"
+    },
+    "Sweave": {
+        "color": "#198ce7",
+        "url": "https://github.com/trending?l=Sweave"
+    },
+    "Swift": {
+        "color": "#F05138",
+        "url": "https://github.com/trending?l=Swift"
+    },
+    "SWIG": {
+        "color": null,
+        "url": "https://github.com/trending?l=SWIG"
+    },
+    "SystemVerilog": {
+        "color": "#DAE1C2",
+        "url": "https://github.com/trending?l=SystemVerilog"
+    },
+    "Tact": {
+        "color": "#48b5ff",
+        "url": "https://github.com/trending?l=Tact"
+    },
+    "Talon": {
+        "color": "#333333",
+        "url": "https://github.com/trending?l=Talon"
+    },
+    "Tcl": {
+        "color": "#e4cc98",
+        "url": "https://github.com/trending?l=Tcl"
+    },
+    "Tcsh": {
+        "color": null,
+        "url": "https://github.com/trending?l=Tcsh"
+    },
+    "Teal": {
+        "color": "#00B1BC",
+        "url": "https://github.com/trending?l=Teal"
+    },
+    "templ": {
+        "color": "#66D0DD",
+        "url": "https://github.com/trending?l=templ"
+    },
+    "Terra": {
+        "color": "#00004c",
+        "url": "https://github.com/trending?l=Terra"
+    },
+    "Terraform Template": {
+        "color": "#7b42bb",
+        "url": "https://github.com/trending?l=Terraform-Template"
+    },
+    "TeX": {
+        "color": "#3D6117",
+        "url": "https://github.com/trending?l=TeX"
+    },
+    "TextGrid": {
+        "color": "#c8506d",
+        "url": "https://github.com/trending?l=TextGrid"
+    },
+    "Textile": {
+        "color": "#ffe7ac",
+        "url": "https://github.com/trending?l=Textile"
+    },
+    "TextMate Properties": {
+        "color": "#df66e4",
+        "url": "https://github.com/trending?l=TextMate-Properties"
+    },
+    "Thrift": {
+        "color": "#D12127",
+        "url": "https://github.com/trending?l=Thrift"
+    },
+    "TI Program": {
+        "color": "#A0AA87",
+        "url": "https://github.com/trending?l=TI-Program"
+    },
+    "TL-Verilog": {
+        "color": "#C40023",
+        "url": "https://github.com/trending?l=TL-Verilog"
+    },
+    "TLA": {
+        "color": "#4b0079",
+        "url": "https://github.com/trending?l=TLA"
+    },
+    "TMDL": {
+        "color": "#f0c913",
+        "url": "https://github.com/trending?l=TMDL"
+    },
+    "Toit": {
+        "color": "#c2c9fb",
+        "url": "https://github.com/trending?l=Toit"
+    },
+    "TOML": {
+        "color": "#9c4221",
+        "url": "https://github.com/trending?l=TOML"
+    },
+    "Tor Config": {
+        "color": "#59316b",
+        "url": "https://github.com/trending?l=Tor-Config"
+    },
+    "Tree-sitter Query": {
+        "color": "#8ea64c",
+        "url": "https://github.com/trending?l=Tree-sitter-Query"
+    },
+    "TSQL": {
+        "color": "#e38c00",
+        "url": "https://github.com/trending?l=TSQL"
+    },
+    "TSV": {
+        "color": "#237346",
+        "url": "https://github.com/trending?l=TSV"
+    },
+    "TSX": {
+        "color": "#3178c6",
+        "url": "https://github.com/trending?l=TSX"
+    },
+    "Turing": {
+        "color": "#cf142b",
+        "url": "https://github.com/trending?l=Turing"
+    },
+    "Twig": {
+        "color": "#c1d026",
+        "url": "https://github.com/trending?l=Twig"
+    },
+    "TXL": {
+        "color": "#0178b8",
+        "url": "https://github.com/trending?l=TXL"
+    },
+    "TypeScript": {
+        "color": "#3178c6",
+        "url": "https://github.com/trending?l=TypeScript"
+    },
+    "TypeSpec": {
+        "color": "#4A3665",
+        "url": "https://github.com/trending?l=TypeSpec"
+    },
+    "Typst": {
+        "color": "#239dad",
+        "url": "https://github.com/trending?l=Typst"
+    },
+    "Unified Parallel C": {
+        "color": "#4e3617",
+        "url": "https://github.com/trending?l=Unified-Parallel-C"
+    },
+    "Unity3D Asset": {
+        "color": "#222c37",
+        "url": "https://github.com/trending?l=Unity3D-Asset"
+    },
+    "Unix Assembly": {
+        "color": null,
+        "url": "https://github.com/trending?l=Unix-Assembly"
+    },
+    "Uno": {
+        "color": "#9933cc",
+        "url": "https://github.com/trending?l=Uno"
+    },
+    "UnrealScript": {
+        "color": "#a54c4d",
+        "url": "https://github.com/trending?l=UnrealScript"
+    },
+    "Untyped Plutus Core": {
+        "color": "#36adbd",
+        "url": "https://github.com/trending?l=Untyped-Plutus-Core"
+    },
+    "UrWeb": {
+        "color": "#ccccee",
+        "url": "https://github.com/trending?l=UrWeb"
+    },
+    "V": {
+        "color": "#4f87c4",
+        "url": "https://github.com/trending?l=V"
+    },
+    "Vala": {
+        "color": "#a56de2",
+        "url": "https://github.com/trending?l=Vala"
+    },
+    "Valve Data Format": {
+        "color": "#f26025",
+        "url": "https://github.com/trending?l=Valve-Data-Format"
+    },
+    "VBA": {
+        "color": "#867db1",
+        "url": "https://github.com/trending?l=VBA"
+    },
+    "VBScript": {
+        "color": "#15dcdc",
+        "url": "https://github.com/trending?l=VBScript"
+    },
+    "vCard": {
+        "color": "#ee2647",
+        "url": "https://github.com/trending?l=vCard"
+    },
+    "VCL": {
+        "color": "#148AA8",
+        "url": "https://github.com/trending?l=VCL"
+    },
+    "Velocity Template Language": {
+        "color": "#507cff",
+        "url": "https://github.com/trending?l=Velocity-Template-Language"
+    },
+    "Vento": {
+        "color": "#ff0080",
+        "url": "https://github.com/trending?l=Vento"
+    },
+    "Verilog": {
+        "color": "#b2b7f8",
+        "url": "https://github.com/trending?l=Verilog"
+    },
+    "VHDL": {
+        "color": "#adb2cb",
+        "url": "https://github.com/trending?l=VHDL"
+    },
+    "Vim Help File": {
+        "color": "#199f4b",
+        "url": "https://github.com/trending?l=Vim-Help-File"
+    },
+    "Vim Script": {
+        "color": "#199f4b",
+        "url": "https://github.com/trending?l=Vim-Script"
+    },
+    "Vim Snippet": {
+        "color": "#199f4b",
+        "url": "https://github.com/trending?l=Vim-Snippet"
+    },
+    "Visual Basic .NET": {
+        "color": "#945db7",
+        "url": "https://github.com/trending?l=Visual-Basic-.NET"
+    },
+    "Visual Basic 6.0": {
+        "color": "#2c6353",
+        "url": "https://github.com/trending?l=Visual-Basic-6.0"
+    },
+    "Volt": {
+        "color": "#1F1F1F",
+        "url": "https://github.com/trending?l=Volt"
+    },
+    "Vue": {
+        "color": "#41b883",
+        "url": "https://github.com/trending?l=Vue"
+    },
+    "Vyper": {
+        "color": "#9F4CF2",
+        "url": "https://github.com/trending?l=Vyper"
+    },
+    "WDL": {
+        "color": "#42f1f4",
+        "url": "https://github.com/trending?l=WDL"
+    },
+    "Web Ontology Language": {
+        "color": "#5b70bd",
+        "url": "https://github.com/trending?l=Web-Ontology-Language"
+    },
+    "WebAssembly": {
+        "color": "#04133b",
+        "url": "https://github.com/trending?l=WebAssembly"
+    },
+    "WebAssembly Interface Type": {
+        "color": "#6250e7",
+        "url": "https://github.com/trending?l=WebAssembly-Interface-Type"
+    },
+    "WebIDL": {
+        "color": null,
+        "url": "https://github.com/trending?l=WebIDL"
+    },
+    "WGSL": {
+        "color": "#1a5e9a",
+        "url": "https://github.com/trending?l=WGSL"
+    },
+    "Whiley": {
+        "color": "#d5c397",
+        "url": "https://github.com/trending?l=Whiley"
+    },
+    "Wikitext": {
+        "color": "#fc5757",
+        "url": "https://github.com/trending?l=Wikitext"
+    },
+    "Windows Registry Entries": {
+        "color": "#52d5ff",
+        "url": "https://github.com/trending?l=Windows-Registry-Entries"
+    },
+    "wisp": {
+        "color": "#7582D1",
+        "url": "https://github.com/trending?l=wisp"
+    },
+    "Witcher Script": {
+        "color": "#ff0000",
+        "url": "https://github.com/trending?l=Witcher-Script"
+    },
+    "Wolfram Language": {
+        "color": "#dd1100",
+        "url": "https://github.com/trending?l=Wolfram-Language"
+    },
+    "Wollok": {
+        "color": "#a23738",
+        "url": "https://github.com/trending?l=Wollok"
+    },
+    "World of Warcraft Addon Data": {
+        "color": "#f7e43f",
+        "url": "https://github.com/trending?l=World-of-Warcraft-Addon-Data"
+    },
+    "Wren": {
+        "color": "#383838",
+        "url": "https://github.com/trending?l=Wren"
+    },
+    "X10": {
+        "color": "#4B6BEF",
+        "url": "https://github.com/trending?l=X10"
+    },
+    "xBase": {
+        "color": "#403a40",
+        "url": "https://github.com/trending?l=xBase"
+    },
+    "XC": {
+        "color": "#99DA07",
+        "url": "https://github.com/trending?l=XC"
+    },
+    "Xmake": {
+        "color": "#22a079",
+        "url": "https://github.com/trending?l=Xmake"
+    },
+    "XML": {
+        "color": "#0060ac",
+        "url": "https://github.com/trending?l=XML"
+    },
+    "XML Property List": {
+        "color": "#0060ac",
+        "url": "https://github.com/trending?l=XML-Property-List"
+    },
+    "Xojo": {
+        "color": "#81bd41",
+        "url": "https://github.com/trending?l=Xojo"
+    },
+    "Xonsh": {
+        "color": "#285EEF",
+        "url": "https://github.com/trending?l=Xonsh"
+    },
+    "XProc": {
+        "color": null,
+        "url": "https://github.com/trending?l=XProc"
+    },
+    "XQuery": {
+        "color": "#5232e7",
+        "url": "https://github.com/trending?l=XQuery"
+    },
+    "XS": {
+        "color": null,
+        "url": "https://github.com/trending?l=XS"
+    },
+    "XSLT": {
+        "color": "#EB8CEB",
+        "url": "https://github.com/trending?l=XSLT"
+    },
+    "Xtend": {
+        "color": "#24255d",
+        "url": "https://github.com/trending?l=Xtend"
+    },
+    "Yacc": {
+        "color": "#4B6C4B",
+        "url": "https://github.com/trending?l=Yacc"
+    },
+    "YAML": {
+        "color": "#cb171e",
+        "url": "https://github.com/trending?l=YAML"
+    },
+    "YARA": {
+        "color": "#220000",
+        "url": "https://github.com/trending?l=YARA"
+    },
+    "YASnippet": {
+        "color": "#32AB90",
+        "url": "https://github.com/trending?l=YASnippet"
+    },
+    "Yul": {
+        "color": "#794932",
+        "url": "https://github.com/trending?l=Yul"
+    },
+    "ZAP": {
+        "color": "#0d665e",
+        "url": "https://github.com/trending?l=ZAP"
+    },
+    "Zeek": {
+        "color": null,
+        "url": "https://github.com/trending?l=Zeek"
+    },
+    "ZenScript": {
+        "color": "#00BCD1",
+        "url": "https://github.com/trending?l=ZenScript"
+    },
+    "Zephir": {
+        "color": "#118f9e",
+        "url": "https://github.com/trending?l=Zephir"
+    },
+    "Zig": {
+        "color": "#ec915c",
+        "url": "https://github.com/trending?l=Zig"
+    },
+    "ZIL": {
+        "color": "#dc75e5",
+        "url": "https://github.com/trending?l=ZIL"
+    },
+    "Zimpl": {
+        "color": "#d67711",
+        "url": "https://github.com/trending?l=Zimpl"
+    },
+    "Zmodel": {
+        "color": "#ff7100",
+        "url": "https://github.com/trending?l=Zmodel"
+    }
+}
diff --git a/src/git/http.rs b/src/git/http.rs
new file mode 100644
index 0000000..0eed64d
--- /dev/null
+++ b/src/git/http.rs
@@ -0,0 +1,292 @@
+use crate::db::models::{Access, Repository, User};
+use crate::db::queries;
+use crate::git::repo::bare_path;
+use crate::state::AppState;
+use anyhow::Result;
+use axum::body::Body;
+use axum::extract::{Path, Query, State};
+use axum::http::{header, HeaderMap, Request, Response, StatusCode};
+use axum::response::IntoResponse;
+use base64::Engine;
+use futures::StreamExt;
+use serde::Deserialize;
+use sqlx::PgPool;
+use std::process::Stdio;
+use tokio::io::AsyncWriteExt;
+use tokio::process::Command;
+use tokio_util::io::ReaderStream;
+
+#[derive(Deserialize)]
+pub struct ServiceQuery {
+    pub service: Option<String>,
+}
+
+pub async fn info_refs(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<ServiceQuery>,
+    headers: HeaderMap,
+) -> impl IntoResponse {
+    let repo_name = strip_git_suffix(&repo);
+    match authorize_git(&state, &owner, repo_name, &headers).await {
+        Ok((access, user, _repo)) => {
+            if !access.can_read() {
+                return if user.is_none() {
+                    unauthorized()
+                } else {
+                    forbidden()
+                };
+            }
+            let service = match q.service.as_deref() {
+                Some("git-upload-pack") => "git-upload-pack",
+                Some("git-receive-pack") => {
+                    // Unauthenticated → 401 so git prompts for credentials.
+                    // Authenticated but no write → 403.
+                    if !access.can_write() {
+                        return if user.is_none() {
+                            unauthorized()
+                        } else {
+                            forbidden()
+                        };
+                    }
+                    "git-receive-pack"
+                }
+                _ => {
+                    return (
+                        StatusCode::BAD_REQUEST,
+                        "missing or unsupported service",
+                    )
+                        .into_response();
+                }
+            };
+            let path = bare_path(&state.config.repos_dir(), &owner, repo_name);
+            match advertise_refs(&path, service).await {
+                Ok(body) => Response::builder()
+                    .status(StatusCode::OK)
+                    .header(
+                        header::CONTENT_TYPE,
+                        format!("application/x-{service}-advertisement"),
+                    )
+                    .header(header::CACHE_CONTROL, "no-cache")
+                    .body(Body::from(body))
+                    .unwrap()
+                    .into_response(),
+                Err(e) => {
+                    tracing::error!("info/refs: {e:#}");
+                    StatusCode::NOT_FOUND.into_response()
+                }
+            }
+        }
+        Err(_) => unauthorized(),
+    }
+}
+
+pub async fn upload_pack(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    headers: HeaderMap,
+    req: Request<Body>,
+) -> impl IntoResponse {
+    let repo_name = strip_git_suffix(&repo);
+    match authorize_git(&state, &owner, repo_name, &headers).await {
+        Ok((access, user, _)) if access.can_read() => {
+            let path = bare_path(&state.config.repos_dir(), &owner, repo_name);
+            rpc_service(&path, "git-upload-pack", req).await
+        }
+        Ok((_, user, _)) => {
+            if user.is_none() {
+                unauthorized()
+            } else {
+                forbidden()
+            }
+        }
+        Err(_) => unauthorized(),
+    }
+}
+
+pub async fn receive_pack(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    headers: HeaderMap,
+    req: Request<Body>,
+) -> impl IntoResponse {
+    let repo_name = strip_git_suffix(&repo);
+    match authorize_git(&state, &owner, repo_name, &headers).await {
+        Ok((access, user, repository)) if access.can_write() => {
+            if repository.archived {
+                return (
+                    StatusCode::FORBIDDEN,
+                    "repository is archived",
+                )
+                    .into_response();
+            }
+            let path = bare_path(&state.config.repos_dir(), &owner, repo_name);
+            let resp = rpc_service(&path, "git-receive-pack", req).await;
+            if let Some(u) = user {
+                let _ = post_push_hooks(&state, &u, &repository, &owner).await;
+            }
+            resp
+        }
+        Ok((_, user, _)) => {
+            if user.is_none() {
+                unauthorized()
+            } else {
+                forbidden()
+            }
+        }
+        Err(_) => unauthorized(),
+    }
+}
+
+async fn post_push_hooks(
+    state: &AppState,
+    user: &User,
+    repo: &Repository,
+    owner: &str,
+) -> Result<()> {
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repo.id),
+        "push",
+        "pushed",
+        serde_json::json!({}),
+    )
+    .await?;
+    queries::bump_commit_activity(&state.pool, user.id, chrono::Utc::now().date_naive(), 1).await?;
+
+    if let Ok(g) = crate::git::open_bare(&state.config.repos_dir(), owner, &repo.name) {
+        let branch = repo.default_branch.clone();
+        if let Ok(files) = crate::git::walk_files(&g, &branch) {
+            let stats = crate::git::languages::detect_languages(&files);
+            queries::set_language_stats(&state.pool, repo.id, stats).await?;
+        }
+    }
+    let _ = sqlx::query("UPDATE repositories SET updated_at = now() WHERE id = $1")
+        .bind(repo.id)
+        .execute(&state.pool)
+        .await;
+    Ok(())
+}
+
+async fn advertise_refs(path: &std::path::Path, service: &str) -> Result<Vec<u8>> {
+    let output = Command::new(service)
+        .arg("--stateless-rpc")
+        .arg("--advertise-refs")
+        .arg(path)
+        .output()
+        .await?;
+    if !output.status.success() {
+        anyhow::bail!(
+            "{} failed: {}",
+            service,
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+    let mut body = Vec::new();
+    let header = format!("# service={service}\n");
+    body.extend_from_slice(pkt_line(&header).as_slice());
+    body.extend_from_slice(b"0000");
+    body.extend_from_slice(&output.stdout);
+    Ok(body)
+}
+
+fn pkt_line(s: &str) -> Vec<u8> {
+    let len = s.len() + 4;
+    format!("{len:04x}{s}").into_bytes()
+}
+
+async fn rpc_service(path: &std::path::Path, service: &str, req: Request<Body>) -> Response<Body> {
+    let mut child = match Command::new(service)
+        .arg("--stateless-rpc")
+        .arg(path)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+    {
+        Ok(c) => c,
+        Err(e) => {
+            tracing::error!("spawn {service}: {e}");
+            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+        }
+    };
+
+    let mut stdin = child.stdin.take().unwrap();
+    let stdout = child.stdout.take().unwrap();
+    let mut body = req.into_body().into_data_stream();
+
+    tokio::spawn(async move {
+        while let Some(Ok(chunk)) = body.next().await {
+            if stdin.write_all(&chunk).await.is_err() {
+                break;
+            }
+        }
+        let _ = stdin.shutdown().await;
+    });
+
+    let stream = ReaderStream::new(stdout);
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(
+            header::CONTENT_TYPE,
+            format!("application/x-{service}-result"),
+        )
+        .header(header::CACHE_CONTROL, "no-cache")
+        .body(Body::from_stream(stream))
+        .unwrap()
+}
+
+async fn authorize_git(
+    state: &AppState,
+    owner: &str,
+    name: &str,
+    headers: &HeaderMap,
+) -> Result<(Access, Option<User>, Repository)> {
+    let (repo, owner_user) = queries::get_repo(&state.pool, owner, name)
+        .await?
+        .ok_or_else(|| anyhow::anyhow!("not found"))?;
+
+    let mut user = crate::auth::current_user(&state.auth, headers).await?;
+    if user.is_none() {
+        user = basic_auth_user(&state.pool, headers).await?;
+    }
+    let access = queries::repo_access(&state.pool, &repo, user.as_ref().map(|u| u.id)).await?;
+    let _ = owner_user;
+    Ok((access, user, repo))
+}
+
+async fn basic_auth_user(pool: &PgPool, headers: &HeaderMap) -> Result<Option<User>> {
+    let Some(auth) = headers.get(header::AUTHORIZATION) else {
+        return Ok(None);
+    };
+    let Ok(s) = auth.to_str() else {
+        return Ok(None);
+    };
+    let Some(b64) = s.strip_prefix("Basic ") else {
+        return Ok(None);
+    };
+    let decoded = base64::engine::general_purpose::STANDARD.decode(b64)?;
+    let pair = String::from_utf8(decoded)?;
+    let (username, _password) = pair.split_once(':').unwrap_or((pair.as_str(), ""));
+    // For HTTP git with Authentik, prefer session cookie. Basic auth accepts username
+    // with any password matching an existing user (dev-friendly). Production should
+    // use deploy tokens later; for now OIDC session or SSH is preferred.
+    Ok(queries::get_user_by_username(pool, username).await?)
+}
+
+fn strip_git_suffix(name: &str) -> &str {
+    name.strip_suffix(".git").unwrap_or(name)
+}
+
+fn unauthorized() -> axum::response::Response {
+    Response::builder()
+        .status(StatusCode::UNAUTHORIZED)
+        .header(header::WWW_AUTHENTICATE, "Basic realm=\"kitgit\"")
+        .body(Body::from("unauthorized"))
+        .unwrap()
+}
+
+fn forbidden() -> axum::response::Response {
+    (StatusCode::FORBIDDEN, "forbidden").into_response()
+}
diff --git a/src/git/languages.rs b/src/git/languages.rs
new file mode 100644
index 0000000..f61356d
--- /dev/null
+++ b/src/git/languages.rs
@@ -0,0 +1,470 @@
+use serde::Deserialize;
+
+use serde_json::{json, Value};
+
+use std::collections::HashMap;
+
+use std::path::Path;
+
+use std::sync::OnceLock;
+
+
+
+const DEFAULT_LANG_COLOR: &str = "#858585";
+
+/// Languages below this share of total bytes are omitted from the bar / list.
+
+const MIN_DISPLAY_PERCENT: f64 = 2.0;
+
+
+
+#[derive(Deserialize)]
+
+struct ColorEntry {
+
+    color: Option<String>,
+
+}
+
+
+
+fn colors_map() -> &'static HashMap<String, String> {
+
+    static MAP: OnceLock<HashMap<String, String>> = OnceLock::new();
+
+    MAP.get_or_init(|| {
+
+        let raw: HashMap<String, ColorEntry> =
+
+            serde_json::from_str(include_str!("github-colors.json")).unwrap_or_default();
+
+        raw.into_iter()
+
+            .filter_map(|(name, entry)| entry.color.map(|color| (name, color)))
+
+            .collect()
+
+    })
+
+}
+
+
+
+/// GitHub linguist color for a language name ([ozh/github-colors](https://github.com/ozh/github-colors)).
+
+pub fn language_color(name: &str) -> &'static str {
+
+    colors_map()
+
+        .get(name)
+
+        .map(String::as_str)
+
+        .unwrap_or(DEFAULT_LANG_COLOR)
+
+}
+
+
+
+pub fn detect_languages(files: &[(String, usize)]) -> Value {
+
+    let mut counts: HashMap<&'static str, usize> = HashMap::new();
+
+    for (path, size) in files {
+
+        if let Some(lang) = lang_for_path(path) {
+
+            *counts.entry(lang).or_default() += *size.max(&1);
+
+        }
+
+    }
+
+    let total: usize = counts.values().sum::<usize>().max(1);
+
+    let mut items: Vec<_> = counts.into_iter().collect();
+
+    items.sort_by(|a, b| b.1.cmp(&a.1));
+
+
+
+    // Drop languages under the display threshold, then renormalize percents.
+
+    let kept: Vec<_> = items
+
+        .into_iter()
+
+        .filter(|(_, bytes)| ((*bytes as f64) * 100.0 / total as f64) >= MIN_DISPLAY_PERCENT)
+
+        .collect();
+
+    let kept_total: usize = kept.iter().map(|(_, b)| *b).sum::<usize>().max(1);
+
+
+
+    let map: serde_json::Map<String, Value> = kept
+
+        .into_iter()
+
+        .map(|(k, v)| {
+
+            let pct = (v as f64) * 100.0 / kept_total as f64;
+
+            (
+
+                k.to_string(),
+
+                json!({
+
+                    "bytes": v,
+
+                    "percent": (pct * 10.0).round() / 10.0,
+
+                    "color": language_color(k),
+
+                }),
+
+            )
+
+        })
+
+        .collect();
+
+    Value::Object(map)
+
+}
+
+
+
+/// Ensure stored language stats include GitHub colors (older rows may omit them).
+
+/// Also filters out languages under 2% and renormalizes displayed percentages.
+
+pub fn enrich_language_colors(stats: Value) -> Value {
+
+    let Value::Object(map) = stats else {
+
+        return stats;
+
+    };
+
+
+
+    let mut items: Vec<(String, f64, usize, String)> = Vec::new();
+
+    for (name, meta) in map {
+
+        let Value::Object(obj) = meta else {
+
+            continue;
+
+        };
+
+        let percent = obj
+
+            .get("percent")
+
+            .and_then(|v| v.as_f64())
+
+            .unwrap_or(0.0);
+
+        if percent < MIN_DISPLAY_PERCENT {
+
+            continue;
+
+        }
+
+        let bytes = obj
+
+            .get("bytes")
+
+            .and_then(|v| v.as_u64())
+
+            .map(|b| b as usize)
+
+            .unwrap_or(0);
+
+        let color = obj
+
+            .get("color")
+
+            .and_then(|c| c.as_str())
+
+            .filter(|c| !c.is_empty())
+
+            .map(|c| c.to_string())
+
+            .unwrap_or_else(|| language_color(&name).to_string());
+
+        items.push((name, percent, bytes, color));
+
+    }
+
+
+
+    let weight_total: f64 = if items.iter().any(|(_, _, b, _)| *b > 0) {
+
+        items.iter().map(|(_, _, b, _)| *b as f64).sum::<f64>().max(1.0)
+
+    } else {
+
+        items.iter().map(|(_, p, _, _)| *p).sum::<f64>().max(1.0)
+
+    };
+
+
+
+    let mut out = serde_json::Map::new();
+
+    for (name, percent, bytes, color) in items {
+
+        let pct = if bytes > 0 {
+
+            (bytes as f64) * 100.0 / weight_total
+
+        } else {
+
+            percent * 100.0 / weight_total
+
+        };
+
+        out.insert(
+
+            name,
+
+            json!({
+
+                "bytes": bytes,
+
+                "percent": (pct * 10.0).round() / 10.0,
+
+                "color": color,
+
+            }),
+
+        );
+
+    }
+
+    Value::Object(out)
+
+}
+
+
+
+fn lang_for_path(path: &str) -> Option<&'static str> {
+
+    let name = Path::new(path)
+
+        .file_name()
+
+        .and_then(|s| s.to_str())
+
+        .unwrap_or("");
+
+    if name == "Dockerfile" || name.starts_with("Dockerfile.") {
+
+        return Some("Dockerfile");
+
+    }
+
+    if name == "Makefile" || name == "makefile" {
+
+        return Some("Makefile");
+
+    }
+
+    let ext = Path::new(path)
+
+        .extension()
+
+        .and_then(|s| s.to_str())
+
+        .unwrap_or("")
+
+        .to_ascii_lowercase();
+
+    Some(match ext.as_str() {
+
+        "rs" => "Rust",
+
+        "go" => "Go",
+
+        "py" => "Python",
+
+        "js" | "mjs" | "cjs" => "JavaScript",
+
+        "ts" | "tsx" => "TypeScript",
+
+        "jsx" => "JavaScript",
+
+        "java" => "Java",
+
+        "kt" | "kts" => "Kotlin",
+
+        "c" | "h" => "C",
+
+        "cpp" | "cc" | "cxx" | "hpp" => "C++",
+
+        "cs" => "C#",
+
+        "rb" => "Ruby",
+
+        "php" => "PHP",
+
+        "swift" => "Swift",
+
+        "scala" => "Scala",
+
+        "hs" => "Haskell",
+
+        "lua" => "Lua",
+
+        "sh" | "bash" | "zsh" => "Shell",
+
+        "ps1" => "PowerShell",
+
+        "html" | "htm" => "HTML",
+
+        "css" => "CSS",
+
+        "scss" | "sass" => "SCSS",
+
+        "md" | "markdown" => "Markdown",
+
+        "json" => "JSON",
+
+        "yml" | "yaml" => "YAML",
+
+        "toml" => "TOML",
+
+        "xml" => "XML",
+
+        "sql" => "SQL",
+
+        "r" => "R",
+
+        "dart" => "Dart",
+
+        "vue" => "Vue",
+
+        "svelte" => "Svelte",
+
+        "zig" => "Zig",
+
+        "nim" => "Nim",
+
+        "ex" | "exs" => "Elixir",
+
+        "erl" => "Erlang",
+
+        "clj" | "cljs" => "Clojure",
+
+        "tf" => "HCL",
+
+        "proto" => "Protocol Buffer",
+
+        _ => return None,
+
+    })
+
+}
+
+
+
+#[cfg(test)]
+
+mod tests {
+
+    use super::*;
+
+
+
+    #[test]
+
+    fn rust_uses_github_color() {
+
+        assert_eq!(language_color("Rust"), "#dea584");
+
+        assert_eq!(language_color("Python"), "#3572A5");
+
+        assert_eq!(language_color("NotARealLang"), DEFAULT_LANG_COLOR);
+
+    }
+
+
+
+    #[test]
+
+    fn detect_includes_color() {
+
+        let stats = detect_languages(&[("main.rs".into(), 100)]);
+
+        let rust = stats.get("Rust").unwrap();
+
+        assert_eq!(rust["color"], "#dea584");
+
+    }
+
+
+
+    #[test]
+
+    fn detect_excludes_under_two_percent() {
+
+        let stats = detect_languages(&[
+
+            ("main.rs".into(), 980),
+
+            ("tiny.py".into(), 10), // ~1%
+
+            ("app.js".into(), 10),  // ~1%
+
+        ]);
+
+        assert!(stats.get("Rust").is_some());
+
+        assert!(stats.get("Python").is_none());
+
+        assert!(stats.get("JavaScript").is_none());
+
+        let pct = stats["Rust"]["percent"].as_f64().unwrap();
+
+        assert!((pct - 100.0).abs() < 0.01);
+
+    }
+
+
+
+    #[test]
+
+    fn enrich_filters_and_renormalizes() {
+
+        let stats = json!({
+
+            "Rust": { "bytes": 90, "percent": 90.0, "color": "#dea584" },
+
+            "Python": { "bytes": 1, "percent": 1.0 },
+
+            "Go": { "bytes": 9, "percent": 9.0 },
+
+        });
+
+        let enriched = enrich_language_colors(stats);
+
+        assert!(enriched.get("Python").is_none());
+
+        assert!(enriched.get("Rust").is_some());
+
+        assert!(enriched.get("Go").is_some());
+
+        let rust = enriched["Rust"]["percent"].as_f64().unwrap();
+
+        let go = enriched["Go"]["percent"].as_f64().unwrap();
+
+        assert!((rust + go - 100.0).abs() < 0.2);
+
+    }
+
+}
+
diff --git a/src/git/lfs.rs b/src/git/lfs.rs
new file mode 100644
index 0000000..2a7e4e5
--- /dev/null
+++ b/src/git/lfs.rs
@@ -0,0 +1,232 @@
+//! Minimal Git LFS batch + basic transfer API.
+
+use crate::db::queries;
+use crate::state::AppState;
+use crate::web::routes::{load_repo_context, AppError, AppResult};
+use axum::body::Body;
+use axum::extract::{Path, State};
+use axum::http::{header, HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Response};
+use axum::Json;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::path::PathBuf;
+use tokio::fs;
+use tokio::io::AsyncWriteExt;
+
+fn lfs_root(state: &AppState) -> PathBuf {
+    state.config.data_dir.join("lfs")
+}
+
+fn oid_path(state: &AppState, oid: &str) -> PathBuf {
+    let prefix = if oid.len() >= 2 { &oid[..2] } else { "xx" };
+    lfs_root(state).join(prefix).join(oid)
+}
+
+#[derive(Deserialize)]
+pub struct LfsBatchRequest {
+    pub operation: String,
+    #[serde(default)]
+    pub transfers: Vec<String>,
+    pub objects: Vec<LfsObjectSpec>,
+}
+
+#[derive(Deserialize, Serialize, Clone)]
+pub struct LfsObjectSpec {
+    pub oid: String,
+    pub size: i64,
+}
+
+#[derive(Serialize)]
+pub struct LfsBatchResponse {
+    pub transfer: String,
+    pub objects: Vec<LfsObjectResult>,
+}
+
+#[derive(Serialize)]
+pub struct LfsObjectResult {
+    pub oid: String,
+    pub size: i64,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub actions: Option<LfsActions>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub error: Option<LfsError>,
+}
+
+#[derive(Serialize)]
+pub struct LfsActions {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub download: Option<LfsAction>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub upload: Option<LfsAction>,
+}
+
+#[derive(Serialize)]
+pub struct LfsAction {
+    pub href: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub header: Option<std::collections::HashMap<String, String>>,
+    pub expires_in: u64,
+}
+
+#[derive(Serialize)]
+pub struct LfsError {
+    pub code: u16,
+    pub message: String,
+}
+
+pub async fn lfs_batch(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Json(req): Json<LfsBatchRequest>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let need_write = req.operation == "upload";
+    if need_write && !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    if !access.can_read() {
+        return Err(AppError::forbidden());
+    }
+
+    let base = state.config.public_url.trim_end_matches('/');
+    let mut objects = Vec::new();
+    for obj in req.objects {
+        if !obj.oid.chars().all(|c| c.is_ascii_hexdigit()) || obj.oid.len() != 64 {
+            objects.push(LfsObjectResult {
+                oid: obj.oid,
+                size: obj.size,
+                actions: None,
+                error: Some(LfsError {
+                    code: 422,
+                    message: "invalid oid".into(),
+                }),
+            });
+            continue;
+        }
+        let path = oid_path(&state, &obj.oid);
+        let exists = path.exists();
+        let href = format!("{base}/{owner}/{repo}/info/lfs/objects/{}/{}", obj.oid, obj.size);
+        let mut actions = LfsActions {
+            download: None,
+            upload: None,
+        };
+        if req.operation == "download" {
+            if exists {
+                actions.download = Some(LfsAction {
+                    href: href.clone(),
+                    header: None,
+                    expires_in: 3600,
+                });
+            } else {
+                objects.push(LfsObjectResult {
+                    oid: obj.oid,
+                    size: obj.size,
+                    actions: None,
+                    error: Some(LfsError {
+                        code: 404,
+                        message: "object not found".into(),
+                    }),
+                });
+                continue;
+            }
+        } else if req.operation == "upload" {
+            if !exists {
+                actions.upload = Some(LfsAction {
+                    href: href.clone(),
+                    header: None,
+                    expires_in: 3600,
+                });
+            }
+            // verify/download optional after upload
+            actions.download = Some(LfsAction {
+                href,
+                header: None,
+                expires_in: 3600,
+            });
+            let _ = queries::register_lfs_object(&state.pool, repository.id, &obj.oid, obj.size).await;
+        }
+        objects.push(LfsObjectResult {
+            oid: obj.oid,
+            size: obj.size,
+            actions: Some(actions),
+            error: None,
+        });
+    }
+
+    Ok((
+        [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
+        Json(LfsBatchResponse {
+            transfer: "basic".into(),
+            objects,
+        }),
+    ))
+}
+
+pub async fn lfs_upload(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, oid, size)): Path<(String, String, String, i64)>,
+    body: Body,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    if oid.len() != 64 || !oid.chars().all(|c| c.is_ascii_hexdigit()) {
+        return Err(AppError::bad("invalid oid"));
+    }
+
+    let path = oid_path(&state, &oid);
+    if let Some(parent) = path.parent() {
+        fs::create_dir_all(parent).await?;
+    }
+
+    let bytes = axum::body::to_bytes(body, 1024 * 1024 * 1024)
+        .await
+        .map_err(|e| AppError::bad(e.to_string()))?;
+    if size >= 0 && bytes.len() as i64 != size {
+        return Err(AppError::bad("size mismatch"));
+    }
+    let mut hasher = Sha256::new();
+    hasher.update(&bytes);
+    let dig = hex::encode(hasher.finalize());
+    if dig != oid {
+        return Err(AppError::bad("oid mismatch"));
+    }
+
+    let mut file = fs::File::create(&path).await?;
+    file.write_all(&bytes).await?;
+    file.flush().await?;
+    queries::register_lfs_object(&state.pool, repository.id, &oid, bytes.len() as i64).await?;
+    Ok(StatusCode::OK.into_response())
+}
+
+pub async fn lfs_download(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, oid, _size)): Path<(String, String, String, i64)>,
+) -> AppResult<Response> {
+    let (_repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_read() {
+        return Err(AppError::forbidden());
+    }
+    let path = oid_path(&state, &oid);
+    if !path.exists() {
+        return Err(AppError::not_found());
+    }
+    let data = fs::read(&path).await?;
+    let len = data.len().to_string();
+    Ok((
+        [
+            (header::CONTENT_TYPE, "application/octet-stream"),
+            (header::CONTENT_LENGTH, len.as_str()),
+        ],
+        data,
+    )
+        .into_response())
+}
diff --git a/src/git/mod.rs b/src/git/mod.rs
new file mode 100644
index 0000000..95a7f9e
--- /dev/null
+++ b/src/git/mod.rs
@@ -0,0 +1,7 @@
+pub mod http;
+pub mod languages;
+pub mod lfs;
+pub mod repo;
+pub mod ssh;
+
+pub use repo::*;
diff --git a/src/git/repo.rs b/src/git/repo.rs
new file mode 100644
index 0000000..b0757f2
--- /dev/null
+++ b/src/git/repo.rs
@@ -0,0 +1,797 @@
+use anyhow::{anyhow, Context, Result};
+use git2::{
+    BranchType, Commit, DiffOptions, ObjectType, Oid, Repository as G2Repo, Signature, Sort, Tree,
+    TreeWalkMode, TreeWalkResult,
+};
+use std::path::{Path, PathBuf};
+
+pub fn bare_path(repos_root: &Path, owner: &str, name: &str) -> PathBuf {
+    repos_root.join(owner).join(format!("{name}.git"))
+}
+
+pub fn init_bare(repos_root: &Path, owner: &str, name: &str, default_branch: &str) -> Result<PathBuf> {
+    let path = bare_path(repos_root, owner, name);
+    if path.exists() {
+        return Err(anyhow!("repository already exists on disk"));
+    }
+    std::fs::create_dir_all(path.parent().unwrap())?;
+    let repo = G2Repo::init_bare(&path)?;
+    repo.set_head(&format!("refs/heads/{default_branch}"))?;
+    // Allow HTTP push receive
+    repo.config()?.set_bool("http.receivepack", true)?;
+    repo.config()?.set_bool("http.uploadpack", true)?;
+    // Git LFS pointer filter (clients still need git-lfs installed)
+    repo.config()?.set_bool("lfs.locksverify", false)?;
+    sync_branch_protection(&path, default_branch, false, true)?;
+    Ok(path)
+}
+
+/// Create an initial README commit on `branch` so the default branch exists.
+pub fn seed_initial_commit(
+    repos_root: &Path,
+    owner: &str,
+    name: &str,
+    branch: &str,
+    author_name: &str,
+    author_email: &str,
+) -> Result<String> {
+    let readme = format!(
+        "# {name}\n\nInitialized on kitgit.\n"
+    );
+    commit_file(
+        repos_root,
+        owner,
+        name,
+        branch,
+        "README.md",
+        readme.as_bytes(),
+        author_name,
+        author_email,
+        "Initial commit",
+    )
+}
+
+pub fn ahead_behind(repo: &G2Repo, branch: &str, base: &str) -> Result<(usize, usize)> {
+    let local = resolve_ref(repo, branch)?;
+    let upstream = resolve_ref(repo, base)?;
+    Ok(repo.graph_ahead_behind(local, upstream)?)
+}
+
+pub fn branch_tip_time(repo: &G2Repo, branch: &str) -> Result<i64> {
+    let oid = resolve_ref(repo, branch)?;
+    let c = repo.find_commit(oid)?;
+    Ok(c.time().seconds())
+}
+
+pub fn delete_branch(repo: &G2Repo, branch: &str) -> Result<()> {
+    let mut b = repo
+        .find_branch(branch, BranchType::Local)
+        .with_context(|| format!("branch {branch}"))?;
+    b.delete()?;
+    Ok(())
+}
+
+pub fn rename_branch(repo: &G2Repo, old: &str, new: &str) -> Result<()> {
+    let mut b = repo
+        .find_branch(old, BranchType::Local)
+        .with_context(|| format!("branch {old}"))?;
+    b.rename(new, false)?;
+    Ok(())
+}
+
+/// Copy a bare repository on disk (for forks).
+pub fn clone_bare(src: &Path, dest: &Path) -> Result<()> {
+    if dest.exists() {
+        return Err(anyhow!("destination already exists"));
+    }
+    if let Some(parent) = dest.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let status = std::process::Command::new("git")
+        .args([
+            "clone",
+            "--bare",
+            "--quiet",
+            src.to_str().unwrap(),
+            dest.to_str().unwrap(),
+        ])
+        .status()?;
+    if !status.success() {
+        return Err(anyhow!("git clone --bare failed"));
+    }
+    let repo = G2Repo::open_bare(dest)?;
+    repo.config()?.set_bool("http.receivepack", true)?;
+    repo.config()?.set_bool("http.uploadpack", true)?;
+    Ok(())
+}
+
+/// Install/update an `update` hook that blocks force-pushes to the default branch.
+pub fn sync_branch_protection(
+    bare: &Path,
+    default_branch: &str,
+    protect: bool,
+    block_force: bool,
+) -> Result<()> {
+    let hooks = bare.join("hooks");
+    std::fs::create_dir_all(&hooks)?;
+    let update = hooks.join("update");
+    if protect && block_force {
+        let script = format!(
+            r#"#!/bin/sh
+refname="$1"
+oldrev="$2"
+newrev="$3"
+default="{default_branch}"
+zero="0000000000000000000000000000000000000000"
+if [ "$refname" = "refs/heads/$default" ] && [ "$oldrev" != "$zero" ] && [ "$newrev" != "$zero" ]; then
+  if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then
+    echo "kitgit: force-push to protected branch '$default' denied" >&2
+    exit 1
+  fi
+fi
+exit 0
+"#
+        );
+        std::fs::write(&update, script)?;
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            let mut perms = std::fs::metadata(&update)?.permissions();
+            perms.set_mode(0o755);
+            std::fs::set_permissions(&update, perms)?;
+        }
+    } else if update.exists() {
+        let _ = std::fs::remove_file(&update);
+    }
+    Ok(())
+}
+
+pub fn remove_bare(repos_root: &Path, owner: &str, name: &str) -> Result<()> {
+    let path = bare_path(repos_root, owner, name);
+    if path.exists() {
+        std::fs::remove_dir_all(&path)?;
+    }
+    Ok(())
+}
+
+pub fn open_bare(repos_root: &Path, owner: &str, name: &str) -> Result<G2Repo> {
+    let path = bare_path(repos_root, owner, name);
+    G2Repo::open_bare(&path).with_context(|| format!("open {}", path.display()))
+}
+
+#[derive(Debug, Clone)]
+pub struct TreeEntry {
+    pub name: String,
+    pub path: String,
+    pub is_dir: bool,
+    pub mode: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommitInfo {
+    pub id: String,
+    pub short_id: String,
+    pub message: String,
+    pub author: String,
+    pub email: String,
+    pub time: i64,
+}
+
+pub fn list_branches(repo: &G2Repo) -> Result<Vec<String>> {
+    let mut out = Vec::new();
+    for b in repo.branches(Some(BranchType::Local))? {
+        let (branch, _) = b?;
+        if let Some(name) = branch.name()? {
+            out.push(name.to_string());
+        }
+    }
+    out.sort();
+    Ok(out)
+}
+
+pub fn resolve_ref(repo: &G2Repo, reference: &str) -> Result<Oid> {
+    let obj = repo.revparse_single(reference)?;
+    Ok(obj.peel_to_commit()?.id())
+}
+
+pub fn list_tree(repo: &G2Repo, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
+    let commit = repo.revparse_single(reference)?.peel_to_commit()?;
+    let tree = commit.tree()?;
+    let tree = if path.is_empty() || path == "/" {
+        tree
+    } else {
+        let entry = tree
+            .get_path(Path::new(path))
+            .with_context(|| format!("path {path}"))?;
+        entry
+            .to_object(repo)?
+            .peel_to_tree()
+            .context("not a directory")?
+    };
+
+    let mut entries = Vec::new();
+    for i in 0..tree.len() {
+        let e = tree.get(i).unwrap();
+        let name = e.name().unwrap_or("").to_string();
+        let is_dir = e.kind() == Some(ObjectType::Tree);
+        let child_path = if path.is_empty() {
+            name.clone()
+        } else {
+            format!("{path}/{name}")
+        };
+        entries.push(TreeEntry {
+            name,
+            path: child_path,
+            is_dir,
+            mode: format!("{:o}", e.filemode()),
+        });
+    }
+    entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
+    Ok(entries)
+}
+
+pub fn read_blob(repo: &G2Repo, reference: &str, path: &str) -> Result<(Vec<u8>, bool)> {
+    let commit = repo.revparse_single(reference)?.peel_to_commit()?;
+    let tree = commit.tree()?;
+    let entry = tree.get_path(Path::new(path))?;
+    let blob = entry.to_object(repo)?.peel_to_blob()?;
+    let data = blob.content().to_vec();
+    let binary = data.iter().any(|&b| b == 0);
+    Ok((data, binary))
+}
+
+/// Find a README in `dir` only (GitHub-style). Empty `dir` = repo root.
+/// Does not fall back to parent/root READMEs when browsing a subdirectory.
+pub fn find_readme(
+    repo: &G2Repo,
+    reference: &str,
+    dir: &str,
+) -> Result<Option<(String, String)>> {
+    let names = [
+        "README.md",
+        "Readme.md",
+        "readme.md",
+        "README.MD",
+        "README",
+        "README.txt",
+    ];
+    let dir = dir.trim_matches('/');
+    for n in names {
+        let path = if dir.is_empty() {
+            n.to_string()
+        } else {
+            format!("{dir}/{n}")
+        };
+        if let Ok((data, binary)) = read_blob(repo, reference, &path) {
+            if !binary {
+                return Ok(Some((n.to_string(), String::from_utf8_lossy(&data).into())));
+            }
+        }
+    }
+    Ok(None)
+}
+
+pub fn list_commits(repo: &G2Repo, reference: &str, limit: usize) -> Result<Vec<CommitInfo>> {
+    let oid = resolve_ref(repo, reference)?;
+    let mut revwalk = repo.revwalk()?;
+    revwalk.push(oid)?;
+    revwalk.set_sorting(Sort::TIME)?;
+    let mut out = Vec::new();
+    for (i, id) in revwalk.enumerate() {
+        if i >= limit {
+            break;
+        }
+        let id = id?;
+        let c = repo.find_commit(id)?;
+        let msg = c.summary().unwrap_or("").to_string();
+        let author = c.author();
+        out.push(CommitInfo {
+            id: id.to_string(),
+            short_id: id.to_string()[..7.min(id.to_string().len())].to_string(),
+            message: msg,
+            author: author.name().unwrap_or("").to_string(),
+            email: author.email().unwrap_or("").to_string(),
+            time: author.when().seconds(),
+        });
+    }
+    Ok(out)
+}
+
+pub fn get_commit(repo: &G2Repo, id: &str) -> Result<CommitInfo> {
+    let oid = Oid::from_str(id)?;
+    let c = repo.find_commit(oid)?;
+    let author = c.author();
+    Ok(CommitInfo {
+        id: oid.to_string(),
+        short_id: oid.to_string()[..7].to_string(),
+        message: c.message().unwrap_or("").to_string(),
+        author: author.name().unwrap_or("").to_string(),
+        email: author.email().unwrap_or("").to_string(),
+        time: author.when().seconds(),
+    })
+}
+
+pub fn commit_diff(repo: &G2Repo, id: &str) -> Result<String> {
+    let oid = Oid::from_str(id)?;
+    let commit = repo.find_commit(oid)?;
+    let tree = commit.tree()?;
+    let parent_tree = if commit.parent_count() > 0 {
+        Some(commit.parent(0)?.tree()?)
+    } else {
+        None
+    };
+    let mut opts = DiffOptions::new();
+    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
+    let mut buf = String::new();
+    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
+        let origin = line.origin();
+        let content = std::str::from_utf8(line.content()).unwrap_or("");
+        match origin {
+            '+' | '-' | ' ' | '@' => {
+                buf.push(origin);
+                buf.push_str(content);
+            }
+            _ => buf.push_str(content),
+        }
+        true
+    })?;
+    Ok(buf)
+}
+
+pub fn branch_diff(repo: &G2Repo, source: &str, target: &str) -> Result<String> {
+    let src = repo.revparse_single(source)?.peel_to_tree()?;
+    let tgt = repo.revparse_single(target)?.peel_to_tree()?;
+    let mut opts = DiffOptions::new();
+    let diff = repo.diff_tree_to_tree(Some(&tgt), Some(&src), Some(&mut opts))?;
+    let mut buf = String::new();
+    diff.print(git2::DiffFormat::Patch, |_d, _h, line| {
+        let origin = line.origin();
+        let content = std::str::from_utf8(line.content()).unwrap_or("");
+        match origin {
+            '+' | '-' | ' ' | '@' => {
+                buf.push(origin);
+                buf.push_str(content);
+            }
+            _ => buf.push_str(content),
+        }
+        true
+    })?;
+    Ok(buf)
+}
+
+pub fn commits_between(repo: &G2Repo, source: &str, target: &str, limit: usize) -> Result<Vec<CommitInfo>> {
+    let src = resolve_ref(repo, source)?;
+    let tgt = resolve_ref(repo, target)?;
+    let mut revwalk = repo.revwalk()?;
+    revwalk.push(src)?;
+    revwalk.hide(tgt)?;
+    revwalk.set_sorting(Sort::TIME)?;
+    let mut out = Vec::new();
+    for (i, id) in revwalk.enumerate() {
+        if i >= limit {
+            break;
+        }
+        let id = id?;
+        let c = repo.find_commit(id)?;
+        let author = c.author();
+        out.push(CommitInfo {
+            id: id.to_string(),
+            short_id: id.to_string()[..7].to_string(),
+            message: c.summary().unwrap_or("").to_string(),
+            author: author.name().unwrap_or("").to_string(),
+            email: author.email().unwrap_or("").to_string(),
+            time: author.when().seconds(),
+        });
+    }
+    Ok(out)
+}
+
+pub fn merge_branches(
+    repos_root: &Path,
+    owner: &str,
+    name: &str,
+    source: &str,
+    target: &str,
+    style: &str,
+    message: &str,
+    author_name: &str,
+    author_email: &str,
+) -> Result<String> {
+    // Clone bare repo into a temp worktree, merge there, push back.
+    // After clone only the default branch exists locally; source/target must
+    // be resolved via origin/* (or created as local tracking branches).
+    let bare = bare_path(repos_root, owner, name);
+    let tmp = tempfile_dir()?;
+    let cleanup = |dir: &Path| {
+        let _ = std::fs::remove_dir_all(dir);
+    };
+
+    let clone = std::process::Command::new("git")
+        .args([
+            "clone",
+            "--quiet",
+            bare.to_str().unwrap(),
+            tmp.to_str().unwrap(),
+        ])
+        .output()
+        .with_context(|| format!("clone {}", bare.display()))?;
+    if !clone.status.success() {
+        cleanup(&tmp);
+        return Err(anyhow!(
+            "clone for merge failed: {}",
+            String::from_utf8_lossy(&clone.stderr).trim()
+        ));
+    }
+
+    let run = |args: &[&str]| -> Result<()> {
+        let out = std::process::Command::new("git")
+            .args(args)
+            .current_dir(&tmp)
+            .output()?;
+        if !out.status.success() {
+            let stderr = String::from_utf8_lossy(&out.stderr);
+            let stderr = stderr.trim();
+            if stderr.is_empty() {
+                return Err(anyhow!("git {} failed", args.join(" ")));
+            }
+            return Err(anyhow!("git {} failed: {}", args.join(" "), stderr));
+        }
+        Ok(())
+    };
+
+    // Identity required for merge/squash/rebase commits.
+    let committer_name = if author_name.trim().is_empty() {
+        "kitgit"
+    } else {
+        author_name.trim()
+    };
+    let committer_email = if author_email.trim().is_empty() {
+        "kitgit@localhost"
+    } else {
+        author_email.trim()
+    };
+    if let Err(e) = (|| -> Result<()> {
+        run(&["config", "user.name", committer_name])?;
+        run(&["config", "user.email", committer_email])?;
+
+        let origin_source = format!("origin/{source}");
+        let origin_target = format!("origin/{target}");
+
+        // Materialize local target from the remote-tracking ref.
+        run(&["checkout", "-B", target, &origin_target])?;
+
+        match style {
+            "squash" => {
+                run(&["merge", "--squash", &origin_source])?;
+                run(&["commit", "-m", message])?;
+            }
+            "rebase" => {
+                run(&["checkout", "-B", source, &origin_source])?;
+                run(&["rebase", target])?;
+                run(&["checkout", target])?;
+                run(&["merge", source])?;
+            }
+            _ => {
+                run(&["merge", "--no-ff", "-m", message, &origin_source])?;
+            }
+        }
+        run(&["push", "origin", target])?;
+        Ok(())
+    })() {
+        cleanup(&tmp);
+        return Err(e);
+    }
+
+    let out = std::process::Command::new("git")
+        .args(["rev-parse", "HEAD"])
+        .current_dir(&tmp)
+        .output()?;
+    cleanup(&tmp);
+    if !out.status.success() {
+        return Err(anyhow!(
+            "git rev-parse HEAD failed: {}",
+            String::from_utf8_lossy(&out.stderr).trim()
+        ));
+    }
+    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
+}
+
+fn tempfile_dir() -> Result<PathBuf> {
+    let p = std::env::temp_dir().join(format!("kitgit-merge-{}", uuid::Uuid::new_v4()));
+    std::fs::create_dir_all(&p)?;
+    Ok(p)
+}
+
+/// Point HEAD at `refs/heads/{branch}` (creates the symbolic ref if needed).
+pub fn set_head_branch(repo: &G2Repo, branch: &str) -> Result<()> {
+    let name = format!("refs/heads/{branch}");
+    repo.set_head(&name)?;
+    Ok(())
+}
+
+/// Commit a single file into `branch` at `path` (relative to repo root).
+pub fn commit_file(
+    repos_root: &Path,
+    owner: &str,
+    name: &str,
+    branch: &str,
+    path: &str,
+    content: &[u8],
+    author_name: &str,
+    author_email: &str,
+    message: &str,
+) -> Result<String> {
+    let path = path.trim_matches('/');
+    if path.is_empty() || path.contains("..") {
+        anyhow::bail!("invalid path");
+    }
+    let repo = open_bare(repos_root, owner, name)?;
+    let sig = Signature::now(author_name, author_email)?;
+
+    let parent = match resolve_ref(&repo, branch) {
+        Ok(oid) => Some(repo.find_commit(oid)?),
+        Err(_) => None,
+    };
+    let parent_tree = parent.as_ref().and_then(|c| c.tree().ok());
+
+    let mut builder = match &parent_tree {
+        Some(t) => repo.treebuilder(Some(t))?,
+        None => repo.treebuilder(None)?,
+    };
+
+    // Build nested trees for path components.
+    let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
+    if parts.is_empty() {
+        anyhow::bail!("invalid path");
+    }
+    fn insert_path(
+        repo: &G2Repo,
+        parent_builder: &mut git2::TreeBuilder<'_>,
+        parent_tree: Option<&Tree>,
+        parts: &[&str],
+        blob_oid: Oid,
+    ) -> Result<()> {
+        if parts.len() == 1 {
+            parent_builder.insert(parts[0], blob_oid, 0o100644)?;
+            return Ok(());
+        }
+        let dirname = parts[0];
+        let rest = &parts[1..];
+        let child_tree = parent_tree
+            .and_then(|t| t.get_name(dirname))
+            .filter(|e| e.kind() == Some(ObjectType::Tree))
+            .and_then(|e| e.to_object(repo).ok())
+            .and_then(|o| o.peel_to_tree().ok());
+        let mut child_builder = match &child_tree {
+            Some(t) => repo.treebuilder(Some(t))?,
+            None => repo.treebuilder(None)?,
+        };
+        insert_path(
+            repo,
+            &mut child_builder,
+            child_tree.as_ref(),
+            rest,
+            blob_oid,
+        )?;
+        let child_oid = child_builder.write()?;
+        parent_builder.insert(dirname, child_oid, 0o040000)?;
+        Ok(())
+    }
+
+    let blob_oid = repo.blob(content)?;
+    insert_path(
+        &repo,
+        &mut builder,
+        parent_tree.as_ref(),
+        &parts,
+        blob_oid,
+    )?;
+    let tree_oid = builder.write()?;
+    let tree = repo.find_tree(tree_oid)?;
+
+    let parents: Vec<&Commit> = parent.as_ref().into_iter().collect();
+    let oid = repo.commit(
+        Some(&format!("refs/heads/{branch}")),
+        &sig,
+        &sig,
+        message,
+        &tree,
+        &parents,
+    )?;
+    Ok(oid.to_string())
+}
+
+/// Create a zip archive of `reference` via `git archive`.
+/// When `path` is `Some`, only that subtree is included (same as `git archive <ref> <path>`).
+pub fn archive_zip(
+    repos_root: &Path,
+    owner: &str,
+    name: &str,
+    reference: &str,
+    path: Option<&str>,
+) -> Result<Vec<u8>> {
+    let bare = bare_path(repos_root, owner, name);
+    let mut cmd = std::process::Command::new("git");
+    cmd.args(["archive", "--format=zip", reference])
+        .current_dir(&bare);
+    if let Some(p) = path {
+        let p = p.trim_matches('/');
+        if !p.is_empty() {
+            if p.contains("..") || p.starts_with('/') || p.contains('\\') {
+                anyhow::bail!("invalid archive path");
+            }
+            cmd.arg(p);
+        }
+    }
+    let out = cmd.output()?;
+    if !out.status.success() {
+        anyhow::bail!(
+            "git archive failed: {}",
+            String::from_utf8_lossy(&out.stderr)
+        );
+    }
+    Ok(out.stdout)
+}
+
+pub fn walk_files(repo: &G2Repo, reference: &str) -> Result<Vec<(String, usize)>> {
+    let commit = repo.revparse_single(reference)?.peel_to_commit()?;
+    let tree = commit.tree()?;
+    let mut files = Vec::new();
+    tree.walk(TreeWalkMode::PreOrder, |root, entry| {
+        if entry.kind() == Some(ObjectType::Blob) {
+            let name = entry.name().unwrap_or("");
+            let path = format!("{root}{name}");
+            if let Ok(obj) = entry.to_object(repo) {
+                if let Ok(blob) = obj.peel_to_blob() {
+                    files.push((path, blob.size()));
+                }
+            }
+        }
+        TreeWalkResult::Ok
+    })?;
+    Ok(files)
+}
+
+#[derive(Debug, Clone)]
+pub struct TagInfo {
+    pub name: String,
+    pub target: String,
+    pub short_id: String,
+    pub message: String,
+    pub time: i64,
+    pub is_annotated: bool,
+}
+
+pub fn list_tags(repo: &G2Repo) -> Result<Vec<TagInfo>> {
+    let names = repo.tag_names(None)?;
+    let mut out = Vec::new();
+    for name in names.iter().flatten() {
+        let obj = match repo.revparse_single(name) {
+            Ok(o) => o,
+            Err(_) => continue,
+        };
+        let (is_annotated, message, time) = if let Ok(tag) = obj.peel_to_tag() {
+            let msg = tag.message().unwrap_or("").trim().to_string();
+            let t = tag
+                .tagger()
+                .map(|s| s.when().seconds())
+                .unwrap_or(0);
+            (true, msg, t)
+        } else {
+            (false, String::new(), 0)
+        };
+        let commit = match obj.peel_to_commit() {
+            Ok(c) => c,
+            Err(_) => continue,
+        };
+        let oid = commit.id();
+        let time = if time == 0 {
+            commit.time().seconds()
+        } else {
+            time
+        };
+        let message = if message.is_empty() {
+            commit.summary().unwrap_or("").to_string()
+        } else {
+            message
+        };
+        out.push(TagInfo {
+            name: name.to_string(),
+            target: oid.to_string(),
+            short_id: oid.to_string().chars().take(7).collect(),
+            message,
+            time,
+            is_annotated,
+        });
+    }
+    out.sort_by(|a, b| b.time.cmp(&a.time).then(a.name.cmp(&b.name)));
+    Ok(out)
+}
+
+pub fn tag_exists(repo: &G2Repo, tag: &str) -> bool {
+    repo.revparse_single(&format!("refs/tags/{tag}")).is_ok()
+        || repo.revparse_single(tag).is_ok()
+}
+
+pub fn delete_tag(repo: &G2Repo, tag: &str) -> Result<()> {
+    let name = tag.trim_start_matches("refs/tags/");
+    repo.tag_delete(name)
+        .with_context(|| format!("delete tag {name}"))?;
+    Ok(())
+}
+
+/// Rename a tag by recreating it at the same target (annotated when possible).
+pub fn rename_tag(repo: &G2Repo, old: &str, new: &str) -> Result<()> {
+    let old = old.trim_start_matches("refs/tags/");
+    let new = new.trim_start_matches("refs/tags/");
+    if old == new {
+        return Ok(());
+    }
+    if tag_exists(repo, new) {
+        return Err(anyhow!("tag {new} already exists"));
+    }
+    let obj = repo
+        .revparse_single(&format!("refs/tags/{old}"))
+        .or_else(|_| repo.revparse_single(old))
+        .with_context(|| format!("tag {old}"))?;
+    let commit = obj.peel_to_commit()?;
+    let message = obj
+        .peel_to_tag()
+        .ok()
+        .and_then(|t| t.message().map(|m| m.to_string()))
+        .unwrap_or_default();
+    let bare = repo.path();
+    create_tag_at(bare, new, &commit.id().to_string(), &message)?;
+    delete_tag(repo, old)?;
+    Ok(())
+}
+
+/// Create an annotated tag at HEAD (or lightweight fallback).
+pub fn create_tag(repos_root: &Path, owner: &str, name: &str, tag: &str, message: &str) -> Result<()> {
+    let bare = bare_path(repos_root, owner, name);
+    create_tag_at(&bare, tag, "HEAD", message)
+}
+
+/// Create a tag pointing at `target` (commit SHA, branch, or other rev).
+pub fn create_tag_at(bare: &Path, tag: &str, target: &str, message: &str) -> Result<()> {
+    let git_dir = bare.to_str().ok_or_else(|| anyhow!("invalid git path"))?;
+    let msg = if message.trim().is_empty() {
+        tag
+    } else {
+        message
+    };
+    let status = std::process::Command::new("git")
+        .args([
+            "--git-dir",
+            git_dir,
+            "tag",
+            "-a",
+            tag,
+            target,
+            "-m",
+            msg,
+        ])
+        .status()?;
+    if !status.success() {
+        let status = std::process::Command::new("git")
+            .args(["--git-dir", git_dir, "tag", tag, target])
+            .status()?;
+        if !status.success() {
+            return Err(anyhow!("failed to create tag {tag}"));
+        }
+    }
+    Ok(())
+}
+
+/// Move an existing tag to a new target (delete + recreate).
+pub fn retarget_tag(bare: &Path, tag: &str, target: &str, message: &str) -> Result<()> {
+    let repo = open_bare_path(bare)?;
+    if tag_exists(&repo, tag) {
+        delete_tag(&repo, tag)?;
+    }
+    create_tag_at(bare, tag, target, message)
+}
+
+pub fn open_bare_path(bare: &Path) -> Result<G2Repo> {
+    Ok(G2Repo::open_bare(bare)?)
+}
diff --git a/src/git/ssh.rs b/src/git/ssh.rs
new file mode 100644
index 0000000..6a622a9
--- /dev/null
+++ b/src/git/ssh.rs
@@ -0,0 +1,313 @@
+use crate::db::queries;
+use crate::git::repo::bare_path;
+use crate::state::AppState;
+use anyhow::{Context, Result};
+use base64::Engine;
+use russh::keys::{decode_secret_key, encode_pkcs8_pem, PublicKey as RPublicKey, PublicKeyBase64};
+use russh::server::{Auth, Handler, Msg, Server, Session};
+use russh::{Channel, ChannelId, CryptoVec, MethodKind, MethodSet};
+use sha2::{Digest, Sha256};
+use std::path::PathBuf;
+use std::process::Stdio;
+use std::sync::Arc;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::sync::Mutex;
+use uuid::Uuid;
+
+fn publickey_methods() -> MethodSet {
+    let mut m = MethodSet::empty();
+    m.push(MethodKind::PublicKey);
+    m
+}
+
+pub async fn run_ssh(state: AppState) -> Result<()> {
+    std::fs::create_dir_all(&state.config.data_dir)?;
+    let key_path = state.config.ssh_host_key_path();
+    let key = load_or_create_host_key(&key_path)?;
+
+    let config = russh::server::Config {
+        inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
+        auth_rejection_time: std::time::Duration::from_secs(1),
+        keys: vec![key],
+        methods: publickey_methods(),
+        ..Default::default()
+    };
+    let bind = state.config.ssh_bind;
+    let config = Arc::new(config);
+    let mut server = SshServer { state };
+    tracing::info!("ssh listening on {bind}");
+    server.run_on_address(config, bind).await?;
+    Ok(())
+}
+
+fn load_or_create_host_key(path: &PathBuf) -> Result<russh::keys::PrivateKey> {
+    if path.exists() {
+        let data = std::fs::read_to_string(path)?;
+        return decode_secret_key(&data, None).context("decode host key");
+    }
+    let key = russh::keys::PrivateKey::random(
+        &mut rand::thread_rng(),
+        russh::keys::Algorithm::Ed25519,
+    )?;
+    let mut pem = Vec::new();
+    encode_pkcs8_pem(&key, &mut pem)?;
+    std::fs::write(path, pem)?;
+    Ok(key)
+}
+
+pub fn fingerprint_ssh_pubkey(line: &str) -> Result<String> {
+    let parts: Vec<_> = line.split_whitespace().collect();
+    if parts.len() < 2 {
+        anyhow::bail!("invalid public key");
+    }
+    let raw = base64::engine::general_purpose::STANDARD.decode(parts[1])?;
+    Ok(ssh_fingerprint_sha256(&raw))
+}
+
+fn fingerprint_public_key(key: &RPublicKey) -> String {
+    // Must match the authorized_keys blob (type || key material), same as
+    // OpenSSH `ssh-keygen -lf` SHA256 fingerprints (base64, no padding).
+    let blob = key.public_key_bytes();
+    ssh_fingerprint_sha256(&blob)
+}
+
+fn ssh_fingerprint_sha256(blob: &[u8]) -> String {
+    let mut h = Sha256::new();
+    h.update(blob);
+    format!(
+        "SHA256:{}",
+        base64::engine::general_purpose::STANDARD_NO_PAD.encode(h.finalize())
+    )
+}
+
+struct SshServer {
+    state: AppState,
+}
+
+impl Server for SshServer {
+    type Handler = SshHandler;
+
+    fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Self::Handler {
+        SshHandler {
+            state: self.state.clone(),
+            user_id: None,
+            stdin: None,
+            receive_meta: None,
+        }
+    }
+}
+
+struct SshHandler {
+    state: AppState,
+    user_id: Option<Uuid>,
+    stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>,
+    receive_meta: Option<(String, String)>,
+}
+
+impl Handler for SshHandler {
+    type Error = anyhow::Error;
+
+    async fn auth_publickey(
+        &mut self,
+        _user: &str,
+        public_key: &RPublicKey,
+    ) -> Result<Auth, Self::Error> {
+        let fp = fingerprint_public_key(public_key);
+        if let Some(user) = queries::user_by_ssh_fingerprint(&self.state.pool, &fp).await? {
+            self.user_id = Some(user.id);
+            return Ok(Auth::Accept);
+        }
+        tracing::debug!("ssh publickey rejected fp={fp}");
+        Ok(Auth::Reject {
+            proceed_with_methods: Some(publickey_methods()),
+        })
+    }
+
+    async fn auth_password(
+        &mut self,
+        _user: &str,
+        _password: &str,
+    ) -> Result<Auth, Self::Error> {
+        // Password auth is not supported — keys only.
+        Ok(Auth::Reject {
+            proceed_with_methods: Some(publickey_methods()),
+        })
+    }
+
+    async fn channel_open_session(
+        &mut self,
+        _channel: Channel<Msg>,
+        _session: &mut Session,
+    ) -> Result<bool, Self::Error> {
+        Ok(true)
+    }
+
+    async fn exec_request(
+        &mut self,
+        channel: ChannelId,
+        data: &[u8],
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        let cmd = String::from_utf8_lossy(data).to_string();
+        let Some(user_id) = self.user_id else {
+            session.channel_failure(channel)?;
+            return Ok(());
+        };
+
+        let (service, repo_path) = match parse_git_command(&cmd) {
+            Ok(v) => v,
+            Err(_) => {
+                session.data(channel, CryptoVec::from_slice(b"unsupported command\n"))?;
+                session.exit_status_request(channel, 1)?;
+                session.eof(channel)?;
+                session.close(channel)?;
+                return Ok(());
+            }
+        };
+        let (owner, name) = split_repo_path(&repo_path)?;
+        let Some((repo, _)) = queries::get_repo(&self.state.pool, &owner, &name).await? else {
+            session.data(channel, CryptoVec::from_slice(b"repository not found\n"))?;
+            session.exit_status_request(channel, 1)?;
+            session.eof(channel)?;
+            session.close(channel)?;
+            return Ok(());
+        };
+        let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?;
+        let allowed = match service {
+            "git-upload-pack" => access.can_read(),
+            "git-receive-pack" => access.can_write() && !repo.archived,
+            _ => false,
+        };
+        if !allowed {
+            session.data(channel, CryptoVec::from_slice(b"access denied\n"))?;
+            session.exit_status_request(channel, 1)?;
+            session.eof(channel)?;
+            session.close(channel)?;
+            return Ok(());
+        }
+
+        let bare = bare_path(&self.state.config.repos_dir(), &owner, &name);
+        let mut child = tokio::process::Command::new(service)
+            .arg(bare.as_os_str())
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .spawn()?;
+
+        let stdin = child.stdin.take().unwrap();
+        let mut stdout = child.stdout.take().unwrap();
+        self.stdin = Some(Arc::new(Mutex::new(stdin)));
+        if service == "git-receive-pack" {
+            self.receive_meta = Some((owner.clone(), name.clone()));
+        }
+
+        let handle = session.handle();
+        tokio::spawn(async move {
+            let mut buf = [0u8; 16384];
+            loop {
+                match stdout.read(&mut buf).await {
+                    Ok(0) => break,
+                    Ok(n) => {
+                        let _ = handle.data(channel, CryptoVec::from_slice(&buf[..n])).await;
+                    }
+                    Err(_) => break,
+                }
+            }
+            let code = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(1) as u32;
+            let _ = handle.eof(channel).await;
+            let _ = handle.exit_status_request(channel, code).await;
+            let _ = handle.close(channel).await;
+        });
+
+        Ok(())
+    }
+
+    async fn data(
+        &mut self,
+        _channel: ChannelId,
+        data: &[u8],
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        if let Some(stdin) = &self.stdin {
+            let mut g = stdin.lock().await;
+            g.write_all(data).await?;
+        }
+        Ok(())
+    }
+
+    async fn channel_eof(
+        &mut self,
+        _channel: ChannelId,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        if let Some(stdin) = self.stdin.take() {
+            let mut g = stdin.lock().await;
+            let _ = g.shutdown().await;
+        }
+        if let (Some(uid), Some((owner, name))) = (self.user_id, self.receive_meta.take()) {
+            if let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await {
+                if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await {
+                    let _ = queries::record_activity(
+                        &self.state.pool,
+                        Some(user.id),
+                        Some(repo.id),
+                        "push",
+                        "pushed",
+                        serde_json::json!({}),
+                    )
+                    .await;
+                    let _ = queries::bump_commit_activity(
+                        &self.state.pool,
+                        user.id,
+                        chrono::Utc::now().date_naive(),
+                        1,
+                    )
+                    .await;
+                    if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name)
+                    {
+                        if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) {
+                            let stats = crate::git::languages::detect_languages(&files);
+                            let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await;
+                        }
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+fn parse_git_command(cmd: &str) -> Result<(&'static str, String)> {
+    let cmd = cmd.trim();
+    if cmd.contains("git-upload-pack") {
+        return Ok(("git-upload-pack", extract_quoted_path(cmd)?));
+    }
+    if cmd.contains("git-receive-pack") {
+        return Ok(("git-receive-pack", extract_quoted_path(cmd)?));
+    }
+    anyhow::bail!("unsupported command")
+}
+
+fn extract_quoted_path(cmd: &str) -> Result<String> {
+    if let Some(start) = cmd.find('\'') {
+        let rest = &cmd[start + 1..];
+        if let Some(end) = rest.find('\'') {
+            return Ok(rest[..end].to_string());
+        }
+    }
+    let parts: Vec<_> = cmd.split_whitespace().collect();
+    Ok(parts
+        .last()
+        .context("no path")?
+        .trim_matches('"')
+        .to_string())
+}
+
+fn split_repo_path(path: &str) -> Result<(String, String)> {
+    let path = path.trim_start_matches('/');
+    let path = path.strip_suffix(".git").unwrap_or(path);
+    let mut parts = path.split('/');
+    let owner = parts.next().context("owner")?.to_string();
+    let name = parts.next().context("name")?.to_string();
+    Ok((owner, name))
+}
diff --git a/src/highlight.rs b/src/highlight.rs
new file mode 100644
index 0000000..dc204fc
--- /dev/null
+++ b/src/highlight.rs
@@ -0,0 +1,61 @@
+use syntect::html::{ClassStyle, ClassedHTMLGenerator};
+use syntect::parsing::SyntaxSet;
+use syntect::util::LinesWithEndings;
+use std::sync::OnceLock;
+
+fn syntaxes() -> &'static SyntaxSet {
+    static SS: OnceLock<SyntaxSet> = OnceLock::new();
+    SS.get_or_init(SyntaxSet::load_defaults_newlines)
+}
+
+/// Highlight `content` for `path`, returning a self-contained `.kg-codeview`.
+///
+/// Syntect keeps spans open across lines, so the highlighted HTML must stay one
+/// contiguous block. Line numbers live in a sibling gutter with the same
+/// monospace metrics / line-height / `white-space: pre` so rows stay aligned.
+pub fn highlight(path: &str, content: &str) -> String {
+    let ss = syntaxes();
+    let syntax = ss
+        .find_syntax_by_extension(
+            std::path::Path::new(path)
+                .extension()
+                .and_then(|e| e.to_str())
+                .unwrap_or(""),
+        )
+        .or_else(|| {
+            std::path::Path::new(path)
+                .file_name()
+                .and_then(|n| n.to_str())
+                .and_then(|n| ss.find_syntax_by_name(n))
+        })
+        .or_else(|| ss.find_syntax_by_first_line(content.lines().next().unwrap_or("")))
+        .unwrap_or_else(|| ss.find_syntax_plain_text());
+
+    let mut generator =
+        ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced);
+    for line in LinesWithEndings::from(content) {
+        let _ = generator.parse_html_for_line_which_includes_newline(line);
+    }
+    // Trim trailing newlines so <pre> visual rows match str::lines() (a trailing
+    // \n inside <pre> otherwise paints an extra blank row vs the gutter).
+    let mut html = generator.finalize();
+    while html.ends_with('\n') {
+        html.pop();
+    }
+
+    let line_count = html.lines().count().max(1);
+    let mut nums = String::with_capacity(line_count * 4);
+    for n in 1..=line_count {
+        if n > 1 {
+            nums.push('\n');
+        }
+        nums.push_str(&n.to_string());
+    }
+
+    format!(
+        "<div class=\"kg-codeview kg-blob\">\
+           <div class=\"kg-blob__nums\" aria-hidden=\"true\">{nums}</div>\
+           <pre class=\"kg-blob__code\"><code class=\"kg-highlight\">{html}</code></pre>\
+         </div>"
+    )
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..d952047
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,57 @@
+mod auth;
+mod config;
+mod db;
+mod git;
+mod highlight;
+mod markdown;
+mod mfa;
+mod state;
+mod web;
+
+use crate::auth::AuthState;
+use crate::config::Config;
+use crate::state::AppState;
+use anyhow::Result;
+use std::sync::Arc;
+use tracing_subscriber::EnvFilter;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
+        )
+        .init();
+
+    let config = Config::load()?;
+    let config = Arc::new(config);
+
+    std::fs::create_dir_all(config.repos_dir())?;
+    std::fs::create_dir_all(config.avatars_dir())?;
+    std::fs::create_dir_all(config.releases_dir())?;
+    std::fs::create_dir_all(config.data_dir.join("lfs"))?;
+    std::fs::create_dir_all(&config.data_dir)?;
+
+    let pool = db::connect(&config.database_url).await?;
+    db::migrate(&pool, std::path::Path::new("./migrations")).await?;
+
+    let auth = AuthState::new(pool.clone(), config.clone()).await?;
+    let state = AppState {
+        pool,
+        config: config.clone(),
+        auth,
+    };
+
+    let ssh_state = state.clone();
+    tokio::spawn(async move {
+        if let Err(e) = git::ssh::run_ssh(ssh_state).await {
+            tracing::error!("ssh server exited: {e:#}");
+        }
+    });
+
+    let app = web::app_router(state);
+    let listener = tokio::net::TcpListener::bind(config.http_bind).await?;
+    tracing::info!("http listening on {}", config.http_bind);
+    axum::serve(listener, app).await?;
+    Ok(())
+}
diff --git a/src/markdown.rs b/src/markdown.rs
new file mode 100644
index 0000000..af41092
--- /dev/null
+++ b/src/markdown.rs
@@ -0,0 +1,209 @@
+use pulldown_cmark::{html, Options, Parser};
+use std::borrow::Cow;
+
+/// Render Markdown to HTML. Protects TeX math from Markdown emphasis/escaping,
+/// then leaves `$…$` / `$$…$$` for client-side KaTeX (+ KaTeX fonts).
+pub fn render_markdown(src: &str) -> String {
+    let src = src.strip_prefix('\u{feff}').unwrap_or(src);
+    let src = preprocess_math_fences(src);
+    let (protected, slots) = protect_math(&src);
+    let mut opts = Options::empty();
+    opts.insert(Options::ENABLE_TABLES);
+    opts.insert(Options::ENABLE_STRIKETHROUGH);
+    opts.insert(Options::ENABLE_TASKLISTS);
+    opts.insert(Options::ENABLE_FOOTNOTES);
+    let parser = Parser::new_ext(&protected, opts);
+    let mut out = String::new();
+    html::push_html(&mut out, parser);
+    restore_math(&out, &slots)
+}
+
+fn preprocess_math_fences(src: &str) -> String {
+    let mut out = String::with_capacity(src.len());
+    let mut lines = src.lines().peekable();
+    while let Some(line) = lines.next() {
+        let trimmed = line.trim();
+        let fence = if trimmed.starts_with("```math") || trimmed.starts_with("```latex") {
+            Some(true)
+        } else if trimmed.starts_with("~~~math") || trimmed.starts_with("~~~latex") {
+            Some(true)
+        } else {
+            None
+        };
+        if fence.is_some() {
+            let closer = if trimmed.starts_with("```") { "```" } else { "~~~" };
+            out.push_str("$$\n");
+            while let Some(inner) = lines.next() {
+                if inner.trim().starts_with(closer) {
+                    break;
+                }
+                out.push_str(inner);
+                out.push('\n');
+            }
+            out.push_str("$$\n");
+            continue;
+        }
+        out.push_str(line);
+        out.push('\n');
+    }
+    out
+}
+
+fn protect_math(src: &str) -> (String, Vec<String>) {
+    let mut out = String::with_capacity(src.len());
+    let mut slots = Vec::new();
+    let bytes = src.as_bytes();
+    let mut i = 0;
+    let mut in_fence = false;
+    let mut fence_char = b'`';
+    let mut fence_len = 0usize;
+
+    while i < bytes.len() {
+        // Track fenced code blocks so we don't treat $ inside them as math.
+        if !in_fence && (bytes[i] == b'`' || bytes[i] == b'~') {
+            let ch = bytes[i];
+            let mut n = 0;
+            while i + n < bytes.len() && bytes[i + n] == ch {
+                n += 1;
+            }
+            if n >= 3 && (i == 0 || bytes[i - 1] == b'\n') {
+                in_fence = true;
+                fence_char = ch;
+                fence_len = n;
+                out.push_str(&src[i..i + n]);
+                i += n;
+                continue;
+            }
+        } else if in_fence && bytes[i] == fence_char {
+            let mut n = 0;
+            while i + n < bytes.len() && bytes[i + n] == fence_char {
+                n += 1;
+            }
+            if n >= fence_len && (i == 0 || bytes[i - 1] == b'\n') {
+                in_fence = false;
+                out.push_str(&src[i..i + n]);
+                i += n;
+                continue;
+            }
+        }
+
+        if !in_fence && bytes[i] == b'$' {
+            let display = i + 1 < bytes.len() && bytes[i + 1] == b'$';
+            let start = if display { i + 2 } else { i + 1 };
+            let delim = if display { "$$" } else { "$" };
+            if let Some(end) = find_math_end(bytes, start, display) {
+                let body = &src[start..end];
+                // Skip empty / whitespace-only (likely not math).
+                if !body.trim().is_empty() {
+                    let token = format!("\u{E000}MATH{}\u{E001}", slots.len());
+                    slots.push(format!("{delim}{body}{delim}"));
+                    out.push_str(&token);
+                    i = end + if display { 2 } else { 1 };
+                    continue;
+                }
+            }
+        }
+
+        // Also protect \( ... \) and \[ ... \]
+        if !in_fence && bytes[i] == b'\\' && i + 1 < bytes.len() {
+            let open = bytes[i + 1];
+            if open == b'(' || open == b'[' {
+                let close = if open == b'(' { b')' } else { b']' };
+                let start = i + 2;
+                if let Some(end) = find_escaped_math_end(bytes, start, close) {
+                    let body = &src[start..end];
+                    if !body.trim().is_empty() {
+                        let token = format!("\u{E000}MATH{}\u{E001}", slots.len());
+                        let delim_open = if open == b'(' { "\\(" } else { "\\[" };
+                        let delim_close = if open == b'(' { "\\)" } else { "\\]" };
+                        slots.push(format!("{delim_open}{body}{delim_close}"));
+                        out.push_str(&token);
+                        i = end + 2;
+                        continue;
+                    }
+                }
+            }
+        }
+
+        out.push(src[i..].chars().next().unwrap());
+        i += src[i..].chars().next().unwrap().len_utf8();
+    }
+
+    (out, slots)
+}
+
+fn find_math_end(bytes: &[u8], start: usize, display: bool) -> Option<usize> {
+    let mut i = start;
+    while i < bytes.len() {
+        if bytes[i] == b'\\' {
+            i += 2;
+            continue;
+        }
+        if display {
+            if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
+                return Some(i);
+            }
+        } else if bytes[i] == b'$' {
+            // Don't treat $$ as inline closer.
+            if i + 1 < bytes.len() && bytes[i + 1] == b'$' {
+                return None;
+            }
+            // No newlines in inline math (common Markdown convention).
+            if bytes[start..i].contains(&b'\n') {
+                return None;
+            }
+            return Some(i);
+        }
+        i += 1;
+    }
+    None
+}
+
+fn find_escaped_math_end(bytes: &[u8], start: usize, close: u8) -> Option<usize> {
+    let mut i = start;
+    while i + 1 < bytes.len() {
+        if bytes[i] == b'\\' && bytes[i + 1] == close {
+            return Some(i);
+        }
+        i += 1;
+    }
+    None
+}
+
+fn restore_math(html: &str, slots: &[String]) -> String {
+    let mut out = Cow::Borrowed(html);
+    for (idx, math) in slots.iter().enumerate() {
+        let token = format!("\u{E000}MATH{idx}\u{E001}");
+        // Markdown may HTML-escape nothing for private-use chars; also handle entities if any.
+        if out.contains(&token) {
+            out = Cow::Owned(out.replace(&token, math));
+        }
+    }
+    out.into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn preserves_inline_math_with_underscore() {
+        let html = render_markdown("Euler $e^{i\\pi}+1=0$ and $x_1$.");
+        assert!(html.contains("$x_1$"), "{html}");
+        assert!(!html.contains("<em>"), "{html}");
+    }
+
+    #[test]
+    fn preserves_display_math() {
+        let html = render_markdown("$$\n\\int_0^1 x^2\\,dx\n$$");
+        assert!(html.contains("$$"), "{html}");
+        assert!(html.contains("\\int_0^1"), "{html}");
+    }
+
+    #[test]
+    fn latex_fence_to_display() {
+        let html = render_markdown("```latex\n\\frac{a}{b}\n```");
+        assert!(html.contains("$$"), "{html}");
+        assert!(html.contains("\\frac{a}{b}"), "{html}");
+    }
+}
diff --git a/src/mfa.rs b/src/mfa.rs
new file mode 100644
index 0000000..b857b2b
--- /dev/null
+++ b/src/mfa.rs
@@ -0,0 +1,161 @@
+//! App-local TOTP MFA (enforced in kitgit after Authentik password succeeds).
+
+use anyhow::{anyhow, Result};
+use base64::Engine;
+use data_encoding::BASE32_NOPAD;
+use hmac::{Hmac, Mac};
+use qrcode::render::svg;
+use qrcode::QrCode;
+use rand::RngCore;
+use sha1::Sha1;
+use sha2::{Digest, Sha256};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+type HmacSha1 = Hmac<Sha1>;
+
+const TOTP_DIGITS: u32 = 6;
+const TOTP_STEP: u64 = 30;
+const TOTP_WINDOW: i64 = 1;
+const RECOVERY_CODE_COUNT: usize = 8;
+
+pub fn generate_totp_secret() -> String {
+    let mut buf = [0u8; 20];
+    rand::thread_rng().fill_bytes(&mut buf);
+    BASE32_NOPAD.encode(&buf)
+}
+
+pub fn otpauth_uri(username: &str, secret_base32: &str, issuer: &str) -> String {
+    let label_raw = format!("{issuer}:{username}");
+    let label = urlencoding::encode(&label_raw);
+    let issuer_q = urlencoding::encode(issuer);
+    format!(
+        "otpauth://totp/{label}?secret={secret_base32}&issuer={issuer_q}&digits={TOTP_DIGITS}&period={TOTP_STEP}"
+    )
+}
+
+pub fn qr_svg_data_uri(otpauth: &str) -> Result<String> {
+    let code = QrCode::new(otpauth.as_bytes()).map_err(|e| anyhow!("qr encode: {e}"))?;
+    let svg = code
+        .render::<svg::Color>()
+        .min_dimensions(180, 180)
+        .dark_color(svg::Color("#111111"))
+        .light_color(svg::Color("#ffffff"))
+        .build();
+    Ok(format!(
+        "data:image/svg+xml;base64,{}",
+        base64::engine::general_purpose::STANDARD.encode(svg.as_bytes())
+    ))
+}
+
+fn hotp(secret: &[u8], counter: u64) -> Result<u32> {
+    let mut mac =
+        HmacSha1::new_from_slice(secret).map_err(|_| anyhow!("invalid hmac key"))?;
+    mac.update(&counter.to_be_bytes());
+    let result = mac.finalize().into_bytes();
+    let offset = (result[19] & 0x0f) as usize;
+    let code = ((u32::from(result[offset]) & 0x7f) << 24)
+        | ((u32::from(result[offset + 1]) & 0xff) << 16)
+        | ((u32::from(result[offset + 2]) & 0xff) << 8)
+        | (u32::from(result[offset + 3]) & 0xff);
+    Ok(code % 10u32.pow(TOTP_DIGITS))
+}
+
+fn decode_secret(secret_base32: &str) -> Result<Vec<u8>> {
+    let cleaned: String = secret_base32
+        .chars()
+        .filter(|c| !c.is_whitespace() && *c != '=')
+        .map(|c| c.to_ascii_uppercase())
+        .collect();
+    BASE32_NOPAD
+        .decode(cleaned.as_bytes())
+        .map_err(|_| anyhow!("invalid authenticator secret"))
+}
+
+fn time_counter(now: u64) -> u64 {
+    now / TOTP_STEP
+}
+
+pub fn verify_totp(secret_base32: &str, code: &str) -> bool {
+    let code = code.trim().replace(' ', "");
+    if code.len() != TOTP_DIGITS as usize || !code.chars().all(|c| c.is_ascii_digit()) {
+        return false;
+    }
+    let Ok(expected) = code.parse::<u32>() else {
+        return false;
+    };
+    let Ok(secret) = decode_secret(secret_base32) else {
+        return false;
+    };
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0);
+    let counter = time_counter(now) as i64;
+    for skew in -TOTP_WINDOW..=TOTP_WINDOW {
+        let c = (counter + skew) as u64;
+        if let Ok(n) = hotp(&secret, c) {
+            if n == expected {
+                return true;
+            }
+        }
+    }
+    false
+}
+
+pub fn generate_recovery_codes() -> Vec<String> {
+    let mut out = Vec::with_capacity(RECOVERY_CODE_COUNT);
+    let mut rng = rand::thread_rng();
+    for _ in 0..RECOVERY_CODE_COUNT {
+        let mut buf = [0u8; 5];
+        rng.fill_bytes(&mut buf);
+        let hex = hex::encode(buf);
+        out.push(format!("{}-{}", &hex[..5], &hex[5..]));
+    }
+    out
+}
+
+pub fn hash_recovery_code(code: &str) -> String {
+    let normalized = code.trim().to_lowercase().replace([' ', '-'], "");
+    let mut h = Sha256::new();
+    h.update(normalized.as_bytes());
+    hex::encode(h.finalize())
+}
+
+pub fn verify_recovery_code(hashes: &[String], code: &str) -> Option<usize> {
+    let want = hash_recovery_code(code);
+    hashes.iter().position(|h| h == &want)
+}
+
+/// Normalize user-facing auth errors so "Authentik" never appears in the browser.
+pub fn sanitize_user_error(msg: &str) -> String {
+    let lower = msg.to_lowercase();
+    if lower.contains("invalid username or password") {
+        return "invalid username or password".into();
+    }
+    if lower.contains("mfa") || lower.contains("authenticator") {
+        return "additional verification required".into();
+    }
+    if lower.contains("misconfigured") || lower.contains("captcha") {
+        return "login is temporarily unavailable".into();
+    }
+    if lower.contains("already taken") {
+        return "username or email already taken".into();
+    }
+    if lower.contains("too short") || lower.contains("at least") {
+        return msg.to_string();
+    }
+    if lower.contains("email") && lower.contains("required") {
+        return msg.to_string();
+    }
+    if lower.contains("signup unavailable") || lower.contains("api token") {
+        return "signup is temporarily unavailable".into();
+    }
+    if lower.contains("authentik") || lower.contains("oidc") {
+        return "authentication failed".into();
+    }
+    // Keep short, safe messages; hide internal detail.
+    if msg.len() < 80 && !lower.contains("http") && !lower.contains("api/") {
+        return msg.to_string();
+    }
+    "authentication failed".into()
+}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..7a62522
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,11 @@
+use crate::auth::AuthState;
+use crate::config::Config;
+use sqlx::PgPool;
+use std::sync::Arc;
+
+#[derive(Clone)]
+pub struct AppState {
+    pub pool: PgPool,
+    pub config: Arc<Config>,
+    pub auth: AuthState,
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
new file mode 100644
index 0000000..dc5ecbf
--- /dev/null
+++ b/src/web/mod.rs
@@ -0,0 +1,288 @@
+pub mod routes;
+pub mod routes_extra;
+pub mod templates;
+
+use crate::git;
+use crate::state::AppState;
+use axum::extract::{DefaultBodyLimit, Request};
+use axum::http::{header, StatusCode};
+use axum::middleware::{from_fn, Next};
+use axum::response::{IntoResponse, Response};
+use axum::routing::{get, post};
+use axum::Router;
+use tower_http::limit::RequestBodyLimitLayer;
+use tower_http::services::ServeDir;
+
+/// Rewrite `/owner/repo.git` → `/owner/repo` and drop a trailing slash so
+/// browser pages and git smart-HTTP both work with classic forge URLs.
+fn normalize_repo_path(path: &str) -> String {
+    let mut path = path.to_string();
+    if path.len() > 1 && path.ends_with('/') {
+        path.pop();
+    }
+    if let Some(idx) = path.find(".git/") {
+        path = format!("{}{}", &path[..idx], &path[idx + 4..]);
+    } else if path.ends_with(".git") {
+        path.truncate(path.len() - 4);
+    }
+    if path.is_empty() {
+        "/".into()
+    } else {
+        path
+    }
+}
+
+async fn normalize_git_url(req: Request, next: Next) -> Response {
+    let path = req.uri().path();
+    let normalized = normalize_repo_path(path);
+    if normalized != path {
+        // 308 keeps method/body (needed for git-receive-pack POST).
+        let dest = match req.uri().query() {
+            Some(q) => format!("{normalized}?{q}"),
+            None => normalized,
+        };
+        return (
+            StatusCode::PERMANENT_REDIRECT,
+            [(header::LOCATION, dest)],
+        )
+            .into_response();
+    }
+    next.run(req).await
+}
+
+pub fn app_router(state: AppState) -> Router {
+    let static_dir = state.config.static_dir.clone();
+    Router::new()
+        .route("/", get(routes::home))
+        .route("/explore", get(routes::explore))
+        .route(
+            "/auth/login",
+            get(routes::auth_login_page).post(routes::auth_login_submit),
+        )
+        .route(
+            "/auth/mfa",
+            get(routes::auth_mfa_page).post(routes::auth_mfa_submit),
+        )
+        .route(
+            "/auth/signup",
+            get(routes::auth_signup_page).post(routes::auth_signup_submit),
+        )
+        .route("/auth/oidc", get(routes::auth_oidc_start))
+        .route("/auth/callback", get(routes::auth_callback))
+        .route("/auth/logout", get(routes::auth_logout))
+        .route("/admin", get(routes::admin_panel))
+        .route("/admin/users", post(routes::admin_set_user))
+        .route("/admin/motd", post(routes::admin_save_motd))
+        .route("/new", get(routes::new_repo_form).post(routes::new_repo))
+        .route(
+            "/settings/profile",
+            get(routes::profile_settings).post(routes::profile_settings_save),
+        )
+        .route("/settings/account", get(routes_extra::account_settings))
+        .route("/settings/mfa", get(routes_extra::mfa_settings))
+        .route("/settings/mfa/enroll", post(routes_extra::mfa_enroll))
+        .route("/settings/mfa/confirm", post(routes_extra::mfa_confirm))
+        .route("/settings/mfa/disable", post(routes_extra::mfa_disable))
+        .route(
+            "/settings/account/username",
+            post(routes_extra::account_change_username),
+        )
+        .route(
+            "/settings/account/password",
+            post(routes_extra::account_change_password),
+        )
+        .route(
+            "/settings/account/privacy",
+            post(routes_extra::account_privacy),
+        )
+        .route(
+            "/settings/account/emails",
+            post(routes_extra::account_add_email),
+        )
+        .route(
+            "/settings/account/emails/{id}/delete",
+            post(routes_extra::account_delete_email),
+        )
+        .route(
+            "/settings/account/sessions/{id}/revoke",
+            post(routes_extra::account_revoke_session),
+        )
+        .route(
+            "/settings/account/sessions/revoke-others",
+            post(routes_extra::account_revoke_others),
+        )
+        .route("/settings/account/export", get(routes_extra::account_export))
+        .route(
+            "/settings/account/delete",
+            post(routes_extra::account_delete),
+        )
+        .route(
+            "/settings/keys",
+            get(routes::keys_settings).post(routes::keys_add),
+        )
+        .route("/settings/keys/{id}/delete", post(routes::keys_delete))
+        .route("/settings/gpg", post(routes_extra::gpg_add))
+        .route("/settings/gpg/{id}/delete", post(routes_extra::gpg_delete))
+        .route("/avatars/{user_id}", get(routes::avatar))
+        .route("/{owner}/{repo}/info/refs", get(git::http::info_refs))
+        .route(
+            "/{owner}/{repo}/git-upload-pack",
+            post(git::http::upload_pack),
+        )
+        .route(
+            "/{owner}/{repo}/git-receive-pack",
+            post(git::http::receive_pack),
+        )
+        .route(
+            "/{owner}/{repo}/info/lfs/objects/batch",
+            post(git::lfs::lfs_batch),
+        )
+        .route(
+            "/{owner}/{repo}/info/lfs/objects/{oid}/{size}",
+            get(git::lfs::lfs_download).put(git::lfs::lfs_upload),
+        )
+        .route("/{owner}/{repo}", get(routes::repo_home))
+        .route("/{owner}/{repo}/tree/{*rest}", get(routes::repo_tree))
+        .route("/{owner}/{repo}/blob/{*rest}", get(routes::repo_blob))
+        .route("/{owner}/{repo}/raw/{*rest}", get(routes_extra::repo_raw))
+        .route("/{owner}/{repo}/star", post(routes_extra::repo_star))
+        .route("/{owner}/{repo}/watch", post(routes_extra::repo_watch))
+        .route("/{owner}/{repo}/fork", post(routes_extra::repo_fork))
+        .route(
+            "/{owner}/{repo}/comments/{comment_id}/react",
+            post(routes_extra::comment_react),
+        )
+        .route("/{owner}/{repo}/commits", get(routes::repo_commits))
+        .route("/{owner}/{repo}/commit/{id}", get(routes::repo_commit))
+        .route("/{owner}/{repo}/diff/{id}", get(routes::repo_diff))
+        .route("/{owner}/{repo}/archive.zip", get(routes::repo_archive_zip))
+        .route("/{owner}/{repo}/upload", post(routes::repo_upload))
+        .route("/{owner}/{repo}/branches", get(routes::repo_branches))
+        .route(
+            "/{owner}/{repo}/branches/{branch}/rename",
+            post(routes_extra::branch_rename),
+        )
+        .route(
+            "/{owner}/{repo}/branches/{branch}/delete",
+            post(routes_extra::branch_delete),
+        )
+        .route("/{owner}/{repo}/tags", post(routes_extra::tag_create))
+        .route(
+            "/{owner}/{repo}/tags/{tag}/rename",
+            post(routes_extra::tag_rename),
+        )
+        .route(
+            "/{owner}/{repo}/tags/{tag}/delete",
+            post(routes_extra::tag_delete),
+        )
+        .route(
+            "/{owner}/{repo}/issues",
+            get(routes::issues_list).post(routes::issue_create),
+        )
+        .route("/{owner}/{repo}/issues/new", get(routes::issue_new))
+        .route(
+            "/{owner}/{repo}/issues/{number}",
+            get(routes::issue_view).post(routes::issue_comment),
+        )
+        .route(
+            "/{owner}/{repo}/issues/{number}/close",
+            post(routes::issue_close),
+        )
+        .route(
+            "/{owner}/{repo}/issues/{number}/reopen",
+            post(routes::issue_reopen),
+        )
+        .route(
+            "/{owner}/{repo}/pulls",
+            get(routes::pulls_list).post(routes::pull_create),
+        )
+        .route("/{owner}/{repo}/pulls/new", get(routes::pull_new))
+        .route(
+            "/{owner}/{repo}/pulls/{number}",
+            get(routes::pull_view).post(routes::pull_comment),
+        )
+        .route(
+            "/{owner}/{repo}/pulls/{number}/merge",
+            post(routes::pull_merge),
+        )
+        .route(
+            "/{owner}/{repo}/pulls/{number}/close",
+            post(routes::pull_close),
+        )
+        .route(
+            "/{owner}/{repo}/releases",
+            get(routes::releases_list).post(routes::release_create),
+        )
+        .route("/{owner}/{repo}/releases/new", get(routes::release_new))
+        .route("/{owner}/{repo}/releases/{tag}", get(routes::release_view))
+        .route(
+            "/{owner}/{repo}/releases/{tag}/edit",
+            get(routes::release_edit).post(routes::release_update),
+        )
+        .route(
+            "/{owner}/{repo}/releases/{tag}/delete",
+            post(routes::release_delete),
+        )
+        .route(
+            "/{owner}/{repo}/releases/{tag}/upload",
+            post(routes::release_upload),
+        )
+        .route(
+            "/{owner}/{repo}/releases/{tag}/assets/{id}/delete",
+            post(routes::asset_delete),
+        )
+        .route("/{owner}/{repo}/assets/{id}", get(routes::asset_download))
+        .route(
+            "/{owner}/{repo}/settings",
+            get(routes::repo_settings).post(routes::repo_settings_save),
+        )
+        .route(
+            "/{owner}/{repo}/settings/collaborators",
+            post(routes::collab_add),
+        )
+        .route(
+            "/{owner}/{repo}/settings/collaborators/{user_id}/remove",
+            post(routes::collab_remove),
+        )
+        .route(
+            "/{owner}/{repo}/settings/branch-rules",
+            post(routes_extra::branch_rule_add),
+        )
+        .route(
+            "/{owner}/{repo}/settings/branch-rules/{id}/delete",
+            post(routes_extra::branch_rule_delete),
+        )
+        .route(
+            "/{owner}/{repo}/settings/danger/archive",
+            post(routes::repo_archive),
+        )
+        .route(
+            "/{owner}/{repo}/settings/danger/delete",
+            post(routes::repo_delete),
+        )
+        .route(
+            "/{owner}/{repo}/settings/danger/transfer",
+            post(routes::repo_transfer),
+        )
+        .route("/{username}", get(routes::profile))
+        .nest_service("/static", ServeDir::new(static_dir))
+        .layer(from_fn(normalize_git_url))
+        .layer(DefaultBodyLimit::max(100 * 1024 * 1024))
+        .layer(RequestBodyLimitLayer::new(100 * 1024 * 1024))
+        .with_state(state)
+}
+
+#[cfg(test)]
+mod path_tests {
+    use super::normalize_repo_path;
+
+    #[test]
+    fn strips_git_suffix_and_slash() {
+        assert_eq!(normalize_repo_path("/o/r.git"), "/o/r");
+        assert_eq!(normalize_repo_path("/o/r.git/"), "/o/r");
+        assert_eq!(normalize_repo_path("/o/r.git/info/refs"), "/o/r/info/refs");
+        assert_eq!(normalize_repo_path("/o/r/"), "/o/r");
+        assert_eq!(normalize_repo_path("/"), "/");
+    }
+}
+
diff --git a/src/web/routes.rs b/src/web/routes.rs
new file mode 100644
index 0000000..1e06180
--- /dev/null
+++ b/src/web/routes.rs
@@ -0,0 +1,3474 @@
+use crate::auth::{
+    self, clear_mfa_pending_cookie, clear_session_cookie, current_user, mfa_pending_cookie_header,
+    mfa_pending_from_headers, session_cookie_header, AuthState, LoginOutcome,
+};
+use crate::db::models::{Access, Comment, Repository, User};
+use crate::db::queries;
+use crate::git;
+use crate::git::ssh::fingerprint_ssh_pubkey;
+use crate::highlight::highlight;
+use crate::markdown::render_markdown;
+use crate::state::AppState;
+use crate::web::templates::*;
+use axum::extract::{Form, Multipart, Path, Query, State};
+use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, LOCATION, SET_COOKIE};
+use axum::http::{HeaderMap, HeaderValue, StatusCode};
+use axum::response::{IntoResponse, Redirect, Response};
+use axum::body::Body;
+use serde::Deserialize;
+use std::path::PathBuf;
+use uuid::Uuid;
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+pub type AppResult<T> = Result<T, AppError>;
+
+pub struct AppError {
+    pub status: StatusCode,
+    pub message: String,
+}
+
+impl AppError {
+    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
+        Self {
+            status,
+            message: message.into(),
+        }
+    }
+    pub fn bad(msg: impl Into<String>) -> Self {
+        Self::new(StatusCode::BAD_REQUEST, msg)
+    }
+    pub fn unauthorized() -> Self {
+        Self::new(StatusCode::UNAUTHORIZED, "login required")
+    }
+    pub fn forbidden() -> Self {
+        Self::new(StatusCode::FORBIDDEN, "forbidden")
+    }
+    pub fn not_found() -> Self {
+        Self::new(StatusCode::NOT_FOUND, "not found")
+    }
+    pub fn internal(err: impl std::fmt::Display) -> Self {
+        tracing::error!("internal: {err:#}");
+        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
+    }
+    pub fn with_message(mut self, msg: String) -> Self {
+        self.message = msg;
+        self
+    }
+}
+
+impl From<anyhow::Error> for AppError {
+    fn from(e: anyhow::Error) -> Self {
+        Self::internal(e)
+    }
+}
+
+impl From<sqlx::Error> for AppError {
+    fn from(e: sqlx::Error) -> Self {
+        Self::internal(e)
+    }
+}
+
+impl From<std::io::Error> for AppError {
+    fn from(e: std::io::Error) -> Self {
+        Self::internal(e)
+    }
+}
+
+impl IntoResponse for AppError {
+    fn into_response(self) -> Response {
+        let body = ErrorTemplate {
+            viewer: None,
+            status: self.status.as_u16(),
+            message: self.message,
+        };
+        (self.status, body).into_response()
+    }
+}
+
+fn redirect(to: &str) -> Response {
+    Redirect::to(to).into_response()
+}
+
+pub fn redirect_see_other(to: &str) -> Response {
+    (StatusCode::SEE_OTHER, [(LOCATION, to.to_string())]).into_response()
+}
+
+fn redirect_with_cookie(to: &str, cookie: HeaderValue) -> Response {
+    let location = HeaderValue::from_str(to).unwrap_or_else(|_| HeaderValue::from_static("/"));
+    (
+        StatusCode::SEE_OTHER,
+        [(LOCATION, location), (SET_COOKIE, cookie)],
+    )
+        .into_response()
+}
+
+fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
+    let location = HeaderValue::from_str(to).unwrap_or_else(|_| HeaderValue::from_static("/"));
+    let mut res = (StatusCode::SEE_OTHER, [(LOCATION, location)]).into_response();
+    for c in cookies {
+        res.headers_mut().append(SET_COOKIE, c);
+    }
+    res
+}
+
+pub fn avatar_url_for(user: &User) -> String {
+    let bust = user.updated_at.timestamp();
+    // Always go through our avatar endpoint when a local file exists.
+    if user.avatar_path.is_some() {
+        return format!("/avatars/{}?v={}", user.id, bust);
+    }
+    // External OIDC pictures are fine in <img src>, but never use our own
+    // /avatars/{id} URL as avatar_url — that creates a redirect loop.
+    if let Some(ref url) = user.avatar_url {
+        if !url.is_empty() && !is_self_avatar_url(url, user.id) {
+            return url.clone();
+        }
+    }
+    format!("/avatars/{}?v={}", user.id, bust)
+}
+
+fn is_self_avatar_url(url: &str, user_id: Uuid) -> bool {
+    let id = user_id.to_string();
+    if url == format!("/avatars/{id}") || url.starts_with(&format!("/avatars/{id}?")) {
+        return true;
+    }
+    // Absolute URLs that point back at this app's avatar route.
+    if let Ok(u) = url::Url::parse(url) {
+        let path = u.path().trim_end_matches('/');
+        if path == format!("/avatars/{id}") {
+            return true;
+        }
+    }
+    false
+}
+
+fn sniff_image_ext(data: &[u8], declared_ct: &str) -> Option<&'static str> {
+    if data.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) {
+        return Some("png");
+    }
+    if data.len() >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
+        return Some("jpg");
+    }
+    if data.len() >= 6 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
+        return Some("gif");
+    }
+    if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
+        return Some("webp");
+    }
+    match declared_ct {
+        "image/png" => Some("png"),
+        "image/jpeg" | "image/jpg" => Some("jpg"),
+        "image/gif" => Some("gif"),
+        "image/webp" => Some("webp"),
+        _ if declared_ct.starts_with("image/") => Some("bin"),
+        _ => None,
+    }
+}
+
+fn initials_avatar_svg(user: &User) -> Vec<u8> {
+    let label = if !user.display_name.trim().is_empty() {
+        user.display_name.as_str()
+    } else {
+        user.username.as_str()
+    };
+    let mut initials = String::new();
+    for part in label.split(|c: char| c.is_whitespace() || c == '-' || c == '_') {
+        if let Some(ch) = part.chars().find(|c| c.is_alphanumeric()) {
+            initials.push(ch.to_ascii_uppercase());
+            if initials.chars().count() == 2 {
+                break;
+            }
+        }
+    }
+    if initials.is_empty() {
+        initials.push('?');
+    }
+    // Deterministic muted tone from user id.
+    let bytes = user.id.as_bytes();
+    let h = bytes.iter().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(*b as u32));
+    let r = 40 + (h % 80) as u8;
+    let g = 40 + ((h >> 8) % 80) as u8;
+    let b = 40 + ((h >> 16) % 80) as u8;
+    let bg = format!("#{r:02x}{g:02x}{b:02x}");
+    let safe: String = initials
+        .chars()
+        .map(|c| match c {
+            '&' => "&amp;".into(),
+            '<' => "&lt;".into(),
+            '>' => "&gt;".into(),
+            '"' => "&quot;".into(),
+            _ => c.to_string(),
+        })
+        .collect();
+    format!(
+        r##"<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
+<rect width="64" height="64" fill="{bg}"/>
+<text x="32" y="34" text-anchor="middle" dominant-baseline="middle"
+ font-family="JetBrains Mono, ui-monospace, monospace" font-size="22" font-weight="700" fill="#f4f1ea">{safe}</text>
+</svg>"##
+    )
+    .into_bytes()
+}
+
+const DIFF_RENDER_MAX_LINES: usize = 50;
+
+fn language_stat_views(stats: serde_json::Value) -> Vec<LanguageStatView> {
+    let enriched = git::languages::enrich_language_colors(stats);
+    let Some(obj) = enriched.as_object() else {
+        return Vec::new();
+    };
+    let mut out: Vec<LanguageStatView> = obj
+        .iter()
+        .map(|(name, meta)| LanguageStatView {
+            name: name.clone(),
+            percent: meta
+                .get("percent")
+                .and_then(|v| v.as_f64())
+                .unwrap_or(0.0),
+            color: meta
+                .get("color")
+                .and_then(|v| v.as_str())
+                .unwrap_or("#858585")
+                .to_string(),
+        })
+        .collect();
+    out.sort_by(|a, b| {
+        b.percent
+            .partial_cmp(&a.percent)
+            .unwrap_or(std::cmp::Ordering::Equal)
+    });
+    out
+}
+
+fn reaction_label(emoji: &str) -> String {
+    match emoji {
+        "+1" => "👍".into(),
+        "-1" => "👎".into(),
+        "heart" => "❤️".into(),
+        "laugh" => "😄".into(),
+        "rocket" => "🚀".into(),
+        "eyes" => "👀".into(),
+        other => other.to_string(),
+    }
+}
+
+fn html_escape(s: &str) -> String {
+    s.chars()
+        .map(|c| match c {
+            '&' => "&amp;".into(),
+            '<' => "&lt;".into(),
+            '>' => "&gt;".into(),
+            '"' => "&quot;".into(),
+            _ => c.to_string(),
+        })
+        .collect()
+}
+
+fn readme_to_html(name: &str, src: &str) -> String {
+    if name.ends_with(".md") || name.ends_with(".MD") {
+        render_markdown(src)
+    } else {
+        format!("<pre>{}</pre>", html_escape(src))
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum CommitRefKind {
+    /// Explicit pull-request wording (`pull request #N`, `Merge pull request #N`).
+    Pull,
+    /// Explicit issue wording (`issue #N`).
+    Issue,
+    /// Closing keywords (`fixes #N`, `closes #N`, …): prefer issue if it exists, else pull.
+    Closing,
+    /// Bare `#N` — default to issues.
+    Bare,
+}
+
+fn classify_commit_ref(before: &str) -> CommitRefKind {
+    let lower: String = before
+        .chars()
+        .rev()
+        .take_while(|c| c.is_ascii_alphabetic() || c.is_whitespace())
+        .collect::<String>()
+        .chars()
+        .rev()
+        .collect::<String>()
+        .to_ascii_lowercase();
+    let trimmed = lower.trim_end();
+    if trimmed.ends_with("pull request") || trimmed.ends_with("pull requests") {
+        return CommitRefKind::Pull;
+    }
+    // "pull #N" but not as part of another word
+    if trimmed == "pull" || trimmed.ends_with(" pull") {
+        return CommitRefKind::Pull;
+    }
+    if trimmed.ends_with("issue") || trimmed.ends_with("issues") {
+        return CommitRefKind::Issue;
+    }
+    for kw in [
+        "fix", "fixes", "fixed", "fixing", "close", "closes", "closed", "closing", "resolve",
+        "resolves", "resolved", "resolving",
+    ] {
+        if trimmed == kw || trimmed.ends_with(&format!(" {kw}")) {
+            return CommitRefKind::Closing;
+        }
+    }
+    CommitRefKind::Bare
+}
+
+fn parse_hash_number(s: &str) -> Option<(usize, i32)> {
+    // `s` starts at '#'
+    if !s.starts_with('#') {
+        return None;
+    }
+    let digits: String = s[1..].chars().take_while(|c| c.is_ascii_digit()).collect();
+    if digits.is_empty() {
+        return None;
+    }
+    let n: i32 = digits.parse().ok()?;
+    if n <= 0 {
+        return None;
+    }
+    Some((1 + digits.len(), n))
+}
+
+/// Escape a commit message and turn GitHub-style `#N` refs into issue/PR links.
+async fn linkify_commit_message(
+    pool: &sqlx::PgPool,
+    repo_id: Uuid,
+    owner: &str,
+    repo: &str,
+    message: &str,
+) -> String {
+    let mut out = String::with_capacity(message.len() + 32);
+    let mut i = 0;
+    let bytes = message.as_bytes();
+    while i < bytes.len() {
+        if bytes[i] == b'#' {
+            if let Some((len, number)) = parse_hash_number(&message[i..]) {
+                // Avoid matching mid-word like `C#1` or `foo#1` — require start or non-alnum before.
+                let ok_boundary = i == 0
+                    || !message[..i]
+                        .chars()
+                        .next_back()
+                        .is_some_and(|c| c.is_ascii_alphanumeric());
+                if ok_boundary {
+                    let kind = classify_commit_ref(&message[..i]);
+                    let href = match kind {
+                        CommitRefKind::Pull => format!("/{owner}/{repo}/pulls/{number}"),
+                        CommitRefKind::Issue | CommitRefKind::Bare => {
+                            format!("/{owner}/{repo}/issues/{number}")
+                        }
+                        CommitRefKind::Closing => {
+                            if queries::get_issue(pool, repo_id, number)
+                                .await
+                                .ok()
+                                .flatten()
+                                .is_some()
+                            {
+                                format!("/{owner}/{repo}/issues/{number}")
+                            } else if queries::get_pull(pool, repo_id, number)
+                                .await
+                                .ok()
+                                .flatten()
+                                .is_some()
+                            {
+                                format!("/{owner}/{repo}/pulls/{number}")
+                            } else {
+                                format!("/{owner}/{repo}/issues/{number}")
+                            }
+                        }
+                    };
+                    let label = html_escape(&message[i..i + len]);
+                    out.push_str(&format!(r#"<a href="{href}">{label}</a>"#));
+                    i += len;
+                    continue;
+                }
+            }
+        }
+        // Copy one char, escaping as needed.
+        let ch = message[i..].chars().next().unwrap();
+        match ch {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            _ => out.push(ch),
+        }
+        i += ch.len_utf8();
+    }
+    out
+}
+
+#[cfg(test)]
+mod commit_ref_tests {
+    use super::{classify_commit_ref, parse_hash_number, CommitRefKind};
+
+    #[test]
+    fn parse_hash_number_basic() {
+        assert_eq!(parse_hash_number("#12 from"), Some((3, 12)));
+        assert_eq!(parse_hash_number("#0"), None);
+        assert_eq!(parse_hash_number("#"), None);
+        assert_eq!(parse_hash_number("12"), None);
+    }
+
+    #[test]
+    fn classify_merge_pull_request() {
+        assert_eq!(
+            classify_commit_ref("Merge pull request "),
+            CommitRefKind::Pull
+        );
+        assert_eq!(classify_commit_ref("fixes "), CommitRefKind::Closing);
+        assert_eq!(classify_commit_ref("closes "), CommitRefKind::Closing);
+        assert_eq!(classify_commit_ref("issue "), CommitRefKind::Issue);
+        assert_eq!(classify_commit_ref("hello "), CommitRefKind::Bare);
+    }
+}
+
+fn diff_line_class(line: &str) -> &'static str {
+    if line.starts_with("+++") || line.starts_with("---") {
+        "kg-diff__meta"
+    } else if line.starts_with('+') {
+        "kg-diff__add"
+    } else if line.starts_with('-') {
+        "kg-diff__del"
+    } else if line.starts_with("@@") {
+        "kg-diff__hunk"
+    } else if line.starts_with('\\') {
+        // Unified-diff meta, e.g. `\ No newline at end of file`
+        "kg-diff__meta"
+    } else if line.starts_with("diff ")
+        || line.starts_with("index ")
+        || line.starts_with("similarity ")
+        || line.starts_with("rename ")
+        || line.starts_with("new file ")
+        || line.starts_with("deleted file ")
+        || line.starts_with("old mode ")
+        || line.starts_with("new mode ")
+    {
+        "kg-diff__meta"
+    } else {
+        ""
+    }
+}
+
+fn append_diff_line(out: &mut String, line: &str) {
+    let class = diff_line_class(line);
+    if class.is_empty() {
+        out.push_str(&html_escape(line));
+    } else {
+        out.push_str(&format!(
+            "<span class=\"{class}\">{}</span>",
+            html_escape(line)
+        ));
+    }
+    out.push('\n');
+}
+
+fn render_diff_html(diff: &str) -> String {
+    let lines: Vec<&str> = diff.lines().collect();
+    let (html, truncated, total) = render_diff_lines(&lines, Some(DIFF_RENDER_MAX_LINES));
+    if truncated {
+        format!(
+            "{html}<p class=\"kg-diff__truncated\">Large diffs are not rendered by default. Showing the first {DIFF_RENDER_MAX_LINES} of {total} lines.</p>"
+        )
+    } else {
+        html
+    }
+}
+
+/// Render unified-diff lines. When `max_lines` is set, stop after that many
+/// lines (GitHub-style truncation for oversized patches).
+fn render_diff_lines(lines: &[&str], max_lines: Option<usize>) -> (String, bool, usize) {
+    let total = lines.len();
+    let limit = max_lines.unwrap_or(total);
+    let truncated = total > limit;
+    let mut out = String::from("<pre class=\"kg-diff\">");
+    for line in lines.iter().take(limit) {
+        append_diff_line(&mut out, line);
+    }
+    out.push_str("</pre>");
+    (out, truncated, total)
+}
+
+fn diff_file_path(header: &str) -> String {
+    // `diff --git a/path b/path` (paths may contain spaces rarely; take b/ side)
+    if let Some(rest) = header.strip_prefix("diff --git ") {
+        let parts: Vec<&str> = rest.split_whitespace().collect();
+        if parts.len() >= 2 {
+            let b = parts[1];
+            return b.strip_prefix("b/").unwrap_or(b).to_string();
+        }
+        if let Some(a) = parts.first() {
+            return a.strip_prefix("a/").unwrap_or(a).to_string();
+        }
+    }
+    "unknown".into()
+}
+
+fn anchor_for_path(path: &str, idx: usize) -> String {
+    let safe: String = path
+        .chars()
+        .map(|c| {
+            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+                c
+            } else {
+                '-'
+            }
+        })
+        .collect();
+    format!("diff-{idx}-{safe}")
+}
+
+fn parse_diff_files(diff: &str) -> Vec<DiffFileView> {
+    let mut files = Vec::new();
+    if diff.trim().is_empty() {
+        return files;
+    }
+    let mut current: Option<(String, Vec<String>)> = None;
+
+    for line in diff.lines() {
+        if line.starts_with("diff --git ") {
+            if let Some((path, lines)) = current.take() {
+                files.push(build_diff_file(files.len(), path, &lines));
+            }
+            current = Some((diff_file_path(line), vec![line.to_string()]));
+        } else if let Some((_, ref mut lines)) = current {
+            lines.push(line.to_string());
+        } else {
+            // Preamble without a file header — treat as one blob.
+            current = Some(("diff".into(), vec![line.to_string()]));
+        }
+    }
+    if let Some((path, lines)) = current {
+        files.push(build_diff_file(files.len(), path, &lines));
+    }
+    files
+}
+
+fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
+    let mut additions = 0u32;
+    let mut deletions = 0u32;
+    let mut binary = false;
+    for line in lines {
+        if line.starts_with("Binary files ") || line.contains("GIT binary patch") {
+            binary = true;
+        }
+        if line.starts_with('+') && !line.starts_with("+++") {
+            additions += 1;
+        } else if line.starts_with('-') && !line.starts_with("---") {
+            deletions += 1;
+        }
+    }
+    let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
+    let (html, truncated, total_lines) = if binary {
+        (
+            String::from(
+                "<pre class=\"kg-diff\"><span class=\"kg-diff__meta\">Binary file not shown.</span></pre>",
+            ),
+            false,
+            refs.len(),
+        )
+    } else {
+        render_diff_lines(&refs, Some(DIFF_RENDER_MAX_LINES))
+    };
+    DiffFileView {
+        anchor: anchor_for_path(&path, idx),
+        path,
+        additions,
+        deletions,
+        html,
+        truncated,
+        total_lines,
+        binary,
+    }
+}
+
+fn latest_commit_view(grepo: &git2::Repository, branch: &str) -> Option<CommitView> {
+    git::list_commits(grepo, branch, 1)
+        .ok()
+        .and_then(|mut v| v.drain(..).next())
+        .map(commit_view)
+}
+
+fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) {
+    let base = state.config.public_url.trim_end_matches('/');
+    // Always advertise the classic `.git` HTTP URL (middleware strips it).
+    let http = format!("{base}/{owner}/{repo}.git");
+    let host = url::Url::parse(&state.config.public_url)
+        .ok()
+        .and_then(|u| u.host_str().map(|h| h.to_string()))
+        .unwrap_or_else(|| "localhost".into());
+    let port = state.config.ssh_bind.port();
+    // Always use ssh:// form. `git@host:2222/path` is parsed as host=host path=2222/path
+    // on port 22 — which hits the system sshd and asks for a password.
+    let ssh = format!("ssh://git@{host}:{port}/{owner}/{repo}.git");
+    (http, ssh)
+}
+
+fn valid_repo_name(name: &str) -> bool {
+    if name.is_empty() || name.len() > 100 {
+        return false;
+    }
+    if name == "." || name == ".." || name.starts_with('.') {
+        return false;
+    }
+    name.chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
+}
+
+pub async fn require_login(auth: &AuthState, headers: &HeaderMap) -> AppResult<User> {
+    current_user(auth, headers)
+        .await?
+        .ok_or_else(AppError::unauthorized)
+}
+
+pub async fn load_repo_context(
+    state: &AppState,
+    owner: &str,
+    repo: &str,
+    headers: &HeaderMap,
+) -> AppResult<(Repository, User, Option<User>, Access)> {
+    let repo = repo.strip_suffix(".git").unwrap_or(repo);
+    let (repository, owner_user) = queries::get_repo(&state.pool, owner, repo)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let viewer = current_user(&state.auth, headers).await?;
+    let access = queries::repo_access(&state.pool, &repository, viewer.as_ref().map(|u| u.id)).await?;
+    if !access.can_read() {
+        return Err(AppError::not_found());
+    }
+    Ok((repository, owner_user, viewer, access))
+}
+
+fn split_ref_path(grepo: &git2::Repository, rest: &str) -> (String, String) {
+    let rest = rest.trim_matches('/');
+    if rest.is_empty() {
+        return ("HEAD".into(), String::new());
+    }
+    if git::resolve_ref(grepo, rest).is_ok() {
+        return (rest.to_string(), String::new());
+    }
+    let parts: Vec<&str> = rest.split('/').collect();
+    for i in (1..parts.len()).rev() {
+        let reference = parts[..i].join("/");
+        if git::resolve_ref(grepo, &reference).is_ok() {
+            return (reference, parts[i..].join("/"));
+        }
+    }
+    (
+        parts[0].to_string(),
+        if parts.len() > 1 {
+            parts[1..].join("/")
+        } else {
+            String::new()
+        },
+    )
+}
+
+fn breadcrumbs(path: &str) -> Vec<(String, String)> {
+    let mut out = Vec::new();
+    if path.is_empty() {
+        return out;
+    }
+    let mut acc = String::new();
+    for part in path.split('/') {
+        if !acc.is_empty() {
+            acc.push('/');
+        }
+        acc.push_str(part);
+        out.push((part.to_string(), acc.clone()));
+    }
+    out
+}
+
+async fn comment_views(
+    pool: &sqlx::PgPool,
+    comments: Vec<Comment>,
+    viewer_id: Option<uuid::Uuid>,
+) -> AppResult<Vec<CommentView>> {
+    let mut out = Vec::with_capacity(comments.len());
+    for c in comments {
+        let author = queries::get_user_by_id(pool, c.author_id)
+            .await?
+            .ok_or_else(AppError::not_found)?;
+        let avatar = avatar_url_for(&author);
+        let reactions = queries::list_reaction_counts(pool, c.id, viewer_id)
+            .await?
+            .into_iter()
+            .map(|(emoji, count, mine)| ReactionView {
+                label: reaction_label(&emoji),
+                emoji,
+                count,
+                mine,
+            })
+            .collect();
+        out.push(CommentView {
+            id: c.id,
+            author,
+            avatar_url: avatar,
+            body_html: render_markdown(&c.body),
+            created_at: c.created_at,
+            reactions,
+        });
+    }
+    Ok(out)
+}
+
+fn checkbox(v: &Option<String>) -> bool {
+    matches!(v.as_deref(), Some("on") | Some("true") | Some("1") | Some("yes"))
+}
+
+/// Split an activity summary into prefix text plus an optional issue/PR link.
+/// Uses `payload.number` with owner/repo from the hydrated event (not regex on display).
+fn activity_summary_parts(
+    kind: &str,
+    summary: &str,
+    payload: &serde_json::Value,
+    owner: Option<&str>,
+    repo: Option<&str>,
+) -> (String, Option<String>, Option<String>) {
+    let Some(number) = payload.get("number").and_then(|v| v.as_i64()) else {
+        return (summary.to_string(), None, None);
+    };
+    let number = number as i32;
+    let (Some(owner), Some(repo)) = (owner, repo) else {
+        return (summary.to_string(), None, None);
+    };
+
+    let (label_candidates, href) = match kind {
+        "issue.open" | "issue.comment" => (
+            vec![format!("issue #{number}")],
+            format!("/{owner}/{repo}/issues/{number}"),
+        ),
+        "pull.open" | "pull.comment" | "pull.merge" => (
+            vec![
+                format!("pull #{number}"),
+                format!("pull request #{number}"),
+            ],
+            format!("/{owner}/{repo}/pulls/{number}"),
+        ),
+        _ => return (summary.to_string(), None, None),
+    };
+
+    for label in label_candidates {
+        if let Some(prefix) = summary.strip_suffix(&label) {
+            return (prefix.to_string(), Some(label), Some(href));
+        }
+    }
+
+    (summary.to_string(), None, None)
+}
+
+fn map_activity_rows(
+    raw: Vec<(
+        crate::db::models::ActivityEvent,
+        Option<crate::db::models::User>,
+        Option<crate::db::models::Repository>,
+        Option<String>,
+    )>,
+) -> Vec<ActivityRow> {
+    let mut activities = Vec::with_capacity(raw.len());
+    for (event, actor, repo, owner_name) in raw {
+        let actor_username = actor.as_ref().map(|u| u.username.clone());
+        let actor_avatar = actor.as_ref().map(avatar_url_for);
+        let repo_name = repo.as_ref().map(|r| r.name.clone());
+        let (summary, ref_label, ref_href) = activity_summary_parts(
+            &event.kind,
+            &event.summary,
+            &event.payload,
+            owner_name.as_deref(),
+            repo_name.as_deref(),
+        );
+        activities.push(ActivityRow {
+            kind: event.kind,
+            summary,
+            ref_label,
+            ref_href,
+            actor_username,
+            actor_avatar,
+            repo_owner: owner_name,
+            repo_name,
+            created_at: event.created_at,
+        });
+    }
+    activities
+}
+
+// ── home / auth ──────────────────────────────────────────────────────────────
+
+pub async fn home(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let viewer = current_user(&state.auth, &headers).await?;
+    let motd = queries::get_setting(&state.pool, "motd").await.unwrap_or_default();
+
+    // Logged-out: branded landing only (no activity / repos).
+    let Some(user) = viewer else {
+        return Ok(HomeTemplate {
+            viewer: None,
+            motd,
+            my_repos: Vec::new(),
+            activities: Vec::new(),
+        });
+    };
+
+    let my_repos = queries::list_user_repos(&state.pool, user.id, Some(user.id))
+        .await
+        .unwrap_or_default();
+    let raw = queries::latest_activity_for_user(&state.pool, user.id, 40).await?;
+    let activities = map_activity_rows(raw);
+    Ok(HomeTemplate {
+        viewer: Some(user),
+        motd,
+        my_repos,
+        activities,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct ExploreQuery {
+    pub q: Option<String>,
+}
+
+pub async fn explore(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Query(q): Query<ExploreQuery>,
+) -> AppResult<impl IntoResponse> {
+    let viewer = current_user(&state.auth, &headers).await?;
+    let query = q.q.unwrap_or_default();
+    let rows = queries::list_public_repos(
+        &state.pool,
+        if query.trim().is_empty() {
+            None
+        } else {
+            Some(query.as_str())
+        },
+        50,
+    )
+    .await?;
+    let repos = rows
+        .into_iter()
+        .map(|(repo, owner)| ExploreRepo { owner, repo })
+        .collect();
+    Ok(ExploreTemplate {
+        viewer,
+        repos,
+        query,
+    })
+}
+
+pub async fn auth_login_page(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    if current_user(&state.auth, &headers).await?.is_some() {
+        return Ok(redirect("/").into_response());
+    }
+    Ok(LoginTemplate {
+        viewer: None,
+        error: None,
+    }
+    .into_response())
+}
+
+#[derive(Deserialize)]
+pub struct LoginForm {
+    pub username: String,
+    pub password: String,
+}
+
+pub async fn auth_login_submit(
+    State(state): State<AppState>,
+    Form(form): Form<LoginForm>,
+) -> AppResult<Response> {
+    match auth::login_with_password(&state.auth, &form.username, &form.password).await {
+        Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies(
+            "/",
+            vec![
+                session_cookie_header(&token, 14 * 24 * 3600),
+                clear_mfa_pending_cookie(),
+            ],
+        )),
+        Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie(
+            "/auth/mfa",
+            mfa_pending_cookie_header(&pending_token, 10 * 60),
+        )),
+        Err(e) => {
+            tracing::warn!("login failed: {e:#}");
+            Ok(LoginTemplate {
+                viewer: None,
+                error: Some(crate::mfa::sanitize_user_error(&e.to_string())),
+            }
+            .into_response())
+        }
+    }
+}
+
+pub async fn auth_mfa_page(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    if current_user(&state.auth, &headers).await?.is_some() {
+        return Ok(redirect("/").into_response());
+    }
+    if mfa_pending_from_headers(&headers).is_none() {
+        return Ok(redirect("/auth/login").into_response());
+    }
+    Ok(MfaChallengeTemplate {
+        viewer: None,
+        error: None,
+    }
+    .into_response())
+}
+
+#[derive(Deserialize)]
+pub struct MfaChallengeForm {
+    pub code: String,
+}
+
+pub async fn auth_mfa_submit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<MfaChallengeForm>,
+) -> AppResult<Response> {
+    let Some(pending) = mfa_pending_from_headers(&headers) else {
+        return Ok(redirect("/auth/login"));
+    };
+    match auth::complete_mfa_login(&state.auth, &pending, &form.code).await {
+        Ok((_user, token)) => Ok(redirect_with_cookies(
+            "/",
+            vec![
+                session_cookie_header(&token, 14 * 24 * 3600),
+                clear_mfa_pending_cookie(),
+            ],
+        )),
+        Err(e) => {
+            tracing::warn!("mfa challenge failed: {e:#}");
+            let msg = crate::mfa::sanitize_user_error(&e.to_string());
+            if msg.contains("expired") {
+                return Ok(redirect_with_cookie("/auth/login", clear_mfa_pending_cookie()));
+            }
+            Ok(MfaChallengeTemplate {
+                viewer: None,
+                error: Some(msg),
+            }
+            .into_response())
+        }
+    }
+}
+
+/// Legacy OIDC browser start — disabled so users never leave kitgit UI.
+pub async fn auth_oidc_start() -> AppResult<Response> {
+    Ok(redirect("/auth/login"))
+}
+
+pub async fn auth_signup_page(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    if current_user(&state.auth, &headers).await?.is_some() {
+        return Ok(redirect("/").into_response());
+    }
+    Ok(SignupTemplate {
+        viewer: None,
+        error: None,
+    }
+    .into_response())
+}
+
+#[derive(Deserialize)]
+pub struct SignupForm {
+    pub username: String,
+    pub email: String,
+    pub password: String,
+    pub display_name: Option<String>,
+}
+
+pub async fn auth_signup_submit(
+    State(state): State<AppState>,
+    Form(form): Form<SignupForm>,
+) -> AppResult<Response> {
+    match auth::signup_with_password(
+        &state.auth,
+        &form.username,
+        &form.email,
+        &form.password,
+        form.display_name.as_deref().unwrap_or(""),
+    )
+    .await
+    {
+        Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies(
+            "/",
+            vec![
+                session_cookie_header(&token, 14 * 24 * 3600),
+                clear_mfa_pending_cookie(),
+            ],
+        )),
+        Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie(
+            "/auth/mfa",
+            mfa_pending_cookie_header(&pending_token, 10 * 60),
+        )),
+        Err(e) => {
+            tracing::warn!("signup failed: {e:#}");
+            Ok(SignupTemplate {
+                viewer: None,
+                error: Some(crate::mfa::sanitize_user_error(&e.to_string())),
+            }
+            .into_response())
+        }
+    }
+}
+
+#[derive(Deserialize)]
+pub struct CallbackQuery {
+    pub code: Option<String>,
+    pub state: Option<String>,
+    pub error: Option<String>,
+}
+
+pub async fn auth_callback(
+    State(state): State<AppState>,
+    Query(q): Query<CallbackQuery>,
+) -> AppResult<Response> {
+    if let Some(err) = q.error {
+        return Err(AppError::bad(format!("oidc error: {err}")));
+    }
+    let code = q.code.ok_or_else(|| AppError::bad("missing code"))?;
+    let st = q.state.ok_or_else(|| AppError::bad("missing state"))?;
+    let (_user, token) = auth::finish_login(&state.auth, &code, &st).await?;
+    let cookie = session_cookie_header(&token, 14 * 24 * 3600);
+    Ok(redirect_with_cookie("/", cookie))
+}
+
+pub async fn auth_logout(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    auth::logout(&state.auth, &headers).await?;
+    Ok(redirect_with_cookie("/", clear_session_cookie()))
+}
+
+// ── site admin panel ─────────────────────────────────────────────────────────
+
+async fn require_site_admin(auth: &AuthState, headers: &HeaderMap) -> AppResult<User> {
+    let user = require_login(auth, headers).await?;
+    if !user.is_site_admin {
+        return Err(AppError::forbidden());
+    }
+    Ok(user)
+}
+
+pub async fn admin_panel(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let users = queries::list_users(&state.pool).await?;
+    let motd = queries::get_setting(&state.pool, "motd").await.unwrap_or_default();
+    Ok(AdminTemplate {
+        viewer: Some(viewer),
+        users,
+        motd,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct AdminToggleForm {
+    pub user_id: Uuid,
+    pub is_site_admin: Option<String>,
+}
+
+pub async fn admin_set_user(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AdminToggleForm>,
+) -> AppResult<Response> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let make_admin = matches!(
+        form.is_site_admin.as_deref(),
+        Some("on") | Some("true") | Some("1") | Some("yes")
+    );
+    // Prevent removing your own admin bit if you're the last admin.
+    if form.user_id == viewer.id && !make_admin {
+        let count = queries::site_admin_count(&state.pool).await?;
+        if count <= 1 {
+            return Err(AppError::bad("cannot remove the last site admin"));
+        }
+    }
+    queries::set_site_admin(&state.pool, form.user_id, make_admin).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+#[derive(Deserialize)]
+pub struct AdminMotdForm {
+    pub motd: Option<String>,
+}
+
+pub async fn admin_save_motd(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AdminMotdForm>,
+) -> AppResult<Response> {
+    let _viewer = require_site_admin(&state.auth, &headers).await?;
+    queries::set_setting(&state.pool, "motd", form.motd.unwrap_or_default().trim()).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+// ── new repo ─────────────────────────────────────────────────────────────────
+
+pub async fn new_repo_form(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let viewer = require_login(&state.auth, &headers).await?;
+    Ok(NewRepoTemplate {
+        viewer: Some(viewer),
+        error: None,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct NewRepoForm {
+    pub name: String,
+    pub description: Option<String>,
+    pub visibility: Option<String>,
+}
+
+pub async fn new_repo(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<NewRepoForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let name = form.name.trim().to_string();
+    let description = form.description.unwrap_or_default();
+    let visibility = match form.visibility.as_deref() {
+        Some("private") => "private",
+        _ => "public",
+    };
+
+    if !valid_repo_name(&name) {
+        return Ok(NewRepoTemplate {
+            viewer: Some(user),
+            error: Some("invalid repository name".into()),
+        }
+        .into_response());
+    }
+
+    if queries::get_repo(&state.pool, &user.username, &name)
+        .await?
+        .is_some()
+    {
+        return Ok(NewRepoTemplate {
+            viewer: Some(user),
+            error: Some("repository already exists".into()),
+        }
+        .into_response());
+    }
+
+    let repo = queries::create_repo(&state.pool, user.id, &name, &description, visibility).await?;
+    git::init_bare(
+        &state.config.repos_dir(),
+        &user.username,
+        &name,
+        &repo.default_branch,
+    )?;
+    let author_name = if user.display_name.is_empty() {
+        user.username.as_str()
+    } else {
+        user.display_name.as_str()
+    };
+    let author_email = if user.email.is_empty() {
+        format!("{}@users.kitgit", user.username)
+    } else {
+        user.email.clone()
+    };
+    let _ = git::seed_initial_commit(
+        &state.config.repos_dir(),
+        &user.username,
+        &name,
+        &repo.default_branch,
+        author_name,
+        &author_email,
+    );
+    let _ = queries::ensure_primary_email(&state.pool, user.id, &user.email).await;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repo.id),
+        "repo.create",
+        "created repository",
+        serde_json::json!({}),
+    )
+    .await?;
+
+    Ok(redirect_see_other(&format!("/{}/{}", user.username, name)))
+}
+
+// ── profile settings ─────────────────────────────────────────────────────────
+
+pub async fn profile_settings(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let avatar_url = avatar_url_for(&user);
+    Ok(ProfileSettingsTemplate {
+        viewer: Some(user.clone()),
+        user,
+        avatar_url,
+        error: None,
+    })
+}
+
+pub async fn profile_settings_save(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    mut multipart: Multipart,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let mut display_name = user.display_name.clone();
+    let mut bio = user.bio.clone();
+    let mut avatar_path: Option<String> = None;
+    let mut avatar_error: Option<String> = None;
+
+    while let Some(field) = multipart.next_field().await.map_err(|e| {
+        // Surface limit/parse failures as a page error instead of a dropped connection.
+        AppError::bad(format!("could not read upload: {e}"))
+    })? {
+        let name = field.name().unwrap_or("").to_string();
+        match name.as_str() {
+            "display_name" => {
+                display_name = field
+                    .text()
+                    .await
+                    .map_err(|e| AppError::bad(e.to_string()))?;
+            }
+            "bio" => {
+                bio = field
+                    .text()
+                    .await
+                    .map_err(|e| AppError::bad(e.to_string()))?;
+            }
+            "avatar" => {
+                let filename = field.file_name().unwrap_or("").to_string();
+                if filename.is_empty() {
+                    let _ = field.bytes().await;
+                    continue;
+                }
+                let ct = field
+                    .content_type()
+                    .unwrap_or("application/octet-stream")
+                    .to_string();
+                let data = match field.bytes().await {
+                    Ok(d) => d,
+                    Err(e) => {
+                        avatar_error = Some(format!("could not read avatar: {e}"));
+                        continue;
+                    }
+                };
+                if data.is_empty() {
+                    continue;
+                }
+                // 5MB soft cap after raising the HTTP body limit.
+                if data.len() > 5 * 1024 * 1024 {
+                    avatar_error = Some("avatar too large (max 5MB)".into());
+                    continue;
+                }
+                let Some(ext) = sniff_image_ext(&data, &ct) else {
+                    avatar_error = Some(
+                        "avatar must be png, jpeg, gif, or webp (heic not supported)".into(),
+                    );
+                    continue;
+                };
+                if let Err(e) = std::fs::create_dir_all(state.config.avatars_dir()) {
+                    return Err(AppError::internal(format!("avatars dir: {e}")));
+                }
+                let rel = format!("{}.{}", user.id, ext);
+                let path = state.config.avatars_dir().join(&rel);
+                if let Err(e) = std::fs::write(&path, &data) {
+                    return Err(AppError::internal(format!("could not save avatar: {e}")));
+                }
+                avatar_path = Some(rel);
+            }
+            _ => {
+                let _ = field.bytes().await;
+            }
+        }
+    }
+
+    let updated = queries::update_user_profile(
+        &state.pool,
+        user.id,
+        display_name.trim(),
+        bio.trim(),
+        avatar_path.as_deref(),
+    )
+    .await?;
+
+    if let Some(err) = avatar_error {
+        let avatar_url = avatar_url_for(&updated);
+        return Ok(ProfileSettingsTemplate {
+            viewer: Some(updated.clone()),
+            user: updated,
+            avatar_url,
+            error: Some(err),
+        }
+        .into_response());
+    }
+
+    Ok(redirect_see_other("/settings/profile"))
+}
+
+// ── SSH keys ─────────────────────────────────────────────────────────────────
+
+pub async fn keys_settings(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let keys = queries::list_ssh_keys(&state.pool, user.id).await?;
+    let gpg_keys = queries::list_gpg_keys(&state.pool, user.id).await?;
+    Ok(KeysSettingsTemplate {
+        viewer: Some(user),
+        keys,
+        gpg_keys,
+        error: None,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct AddKeyForm {
+    pub name: String,
+    pub public_key: String,
+}
+
+pub async fn keys_add(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AddKeyForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let name = form.name.trim();
+    let public_key = form.public_key.trim();
+    if name.is_empty() || public_key.is_empty() {
+        let keys = queries::list_ssh_keys(&state.pool, user.id).await?;
+        let gpg_keys = queries::list_gpg_keys(&state.pool, user.id).await?;
+        return Ok(KeysSettingsTemplate {
+            viewer: Some(user),
+            keys,
+            gpg_keys,
+            error: Some("name and public key required".into()),
+        }
+        .into_response());
+    }
+    let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?;
+    queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp)
+        .await
+        .map_err(|e| AppError::bad(format!("could not add key: {e}")))?;
+    Ok(redirect_see_other("/settings/keys"))
+}
+
+pub async fn keys_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::delete_ssh_key(&state.pool, user.id, id).await?;
+    Ok(redirect_see_other("/settings/keys"))
+}
+
+// ── avatar ───────────────────────────────────────────────────────────────────
+
+pub async fn avatar(
+    State(state): State<AppState>,
+    Path(user_id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = queries::get_user_by_id(&state.pool, user_id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+
+    if let Some(ref rel) = user.avatar_path {
+        // Only allow basename under avatars_dir (no path traversal).
+        let base = std::path::Path::new(rel)
+            .file_name()
+            .map(std::path::PathBuf::from)
+            .unwrap_or_else(|| std::path::PathBuf::from(rel));
+        let path = state.config.avatars_dir().join(base);
+        if path.is_file() {
+            let data = std::fs::read(&path)?;
+            let mime = mime_guess::from_path(&path)
+                .first_or_octet_stream()
+                .essence_str()
+                .to_string();
+            return Ok(Response::builder()
+                .status(StatusCode::OK)
+                .header(CONTENT_TYPE, mime)
+                // Cache-bust via ?v= on URLs; avoid year-long immutable (stale avatars).
+                .header(
+                    axum::http::header::CACHE_CONTROL,
+                    "public, max-age=3600, must-revalidate",
+                )
+                .body(Body::from(data))
+                .unwrap());
+        }
+    }
+
+    // Never HTTP-redirect from /avatars/{id}. Self-referential avatar_url values
+    // (e.g. "/avatars/{id}") make the browser loop forever on <img>.
+    let svg = initials_avatar_svg(&user);
+    Ok(Response::builder()
+        .status(StatusCode::OK)
+        .header(CONTENT_TYPE, "image/svg+xml; charset=utf-8")
+        .header(axum::http::header::CACHE_CONTROL, "public, max-age=60")
+        .body(Body::from(svg))
+        .unwrap())
+}
+
+// ── profile ──────────────────────────────────────────────────────────────────
+
+pub async fn profile(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(username): Path<String>,
+) -> AppResult<impl IntoResponse> {
+    let viewer = current_user(&state.auth, &headers).await?;
+    let profile = queries::get_user_by_username(&state.pool, &username)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let viewer_id = viewer.as_ref().map(|u| u.id);
+    let repos = queries::list_user_repos(&state.pool, profile.id, viewer_id).await?;
+    let starred_raw =
+        queries::list_public_starred_repos(&state.pool, profile.id, 30).await?;
+    let starred = starred_raw
+        .into_iter()
+        .map(|(repo, owner)| ExploreRepo { owner, repo })
+        .collect();
+    let watched_raw =
+        queries::latest_activity_for_watched_repos(&state.pool, profile.id, 30).await?;
+    let watched_activity = map_activity_rows(watched_raw);
+    let graph = queries::commit_graph(&state.pool, profile.id, 365).await?;
+    let is_self = viewer
+        .as_ref()
+        .map(|v| v.id == profile.id)
+        .unwrap_or(false);
+    let avatar_url = avatar_url_for(&profile);
+    let has_activity = graph.iter().any(|d| d.count > 0);
+    Ok(ProfileTemplate {
+        viewer,
+        profile,
+        avatar_url,
+        repos,
+        starred,
+        watched_activity,
+        graph,
+        has_activity,
+        is_self,
+    })
+}
+
+// ── repository browse ────────────────────────────────────────────────────────
+
+pub async fn repo_home(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let owner_avatar = avatar_url_for(&owner_user);
+
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo).ok();
+    let current_branch = repository.default_branch.clone();
+    let mut branches = Vec::new();
+    let mut entries = Vec::new();
+    let mut readme_html = None;
+    let mut empty = true;
+    let mut languages = queries::get_language_stats(&state.pool, repository.id).await?;
+
+    if let Some(ref g) = grepo {
+        branches = git::list_branches(g).unwrap_or_default();
+        if git::resolve_ref(g, &current_branch).is_ok() {
+            empty = false;
+            entries = git::list_tree(g, &current_branch, "")
+                .unwrap_or_default()
+                .into_iter()
+                .map(|e| TreeEntryView {
+                    name: e.name,
+                    path: e.path,
+                    is_dir: e.is_dir,
+                    mode: e.mode,
+                })
+                .collect();
+            if let Ok(Some((name, src))) = git::find_readme(g, &current_branch, "") {
+                readme_html = Some(readme_to_html(&name, &src));
+            }
+            if languages.as_object().map(|o| o.is_empty()).unwrap_or(true) {
+                if let Ok(files) = git::walk_files(g, &current_branch) {
+                    let stats = git::languages::detect_languages(&files);
+                    let _ = queries::set_language_stats(&state.pool, repository.id, stats.clone())
+                        .await;
+                    languages = stats;
+                }
+            }
+        }
+    }
+
+    let latest_commit = grepo
+        .as_ref()
+        .and_then(|g| latest_commit_view(g, &current_branch));
+    let languages = language_stat_views(languages);
+
+    let forked_from = if let Some(fid) = repository.fork_of_id {
+        match queries::get_repo_by_id(&state.pool, fid).await? {
+            Some(parent) => {
+                let parent_owner = queries::get_user_by_id(&state.pool, parent.owner_id)
+                    .await?
+                    .map(|u| u.username)
+                    .unwrap_or_else(|| "unknown".into());
+                Some((parent_owner, parent.name))
+            }
+            None => None,
+        }
+    } else {
+        None
+    };
+    let (starred, watching) = if let Some(ref u) = viewer {
+        (
+            queries::is_starred(&state.pool, repository.id, u.id).await?,
+            queries::is_watching(&state.pool, repository.id, u.id).await?,
+        )
+    } else {
+        (false, false)
+    };
+
+    Ok(RepoHomeTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        owner_avatar,
+        clone_http,
+        clone_ssh,
+        branches,
+        current_branch,
+        entries,
+        readme_html,
+        languages,
+        empty,
+        latest_commit,
+        forked_from,
+        starred,
+        watching,
+    })
+}
+
+pub async fn repo_tree(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, rest)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let (branch, path) = split_ref_path(&grepo, &rest);
+    let entries = git::list_tree(&grepo, &branch, &path)
+        .map_err(|e| AppError::not_found().with_message(e.to_string()))?
+        .into_iter()
+        .map(|e| TreeEntryView {
+            name: e.name,
+            path: e.path,
+            is_dir: e.is_dir,
+            mode: e.mode,
+        })
+        .collect();
+    let readme_html = match git::find_readme(&grepo, &branch, &path) {
+        Ok(Some((name, src))) => Some(readme_to_html(&name, &src)),
+        _ => None,
+    };
+    let branches = git::list_branches(&grepo).unwrap_or_default();
+    let latest_commit = latest_commit_view(&grepo, &branch);
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(RepoTreeTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        branches,
+        branch,
+        path: path.clone(),
+        breadcrumbs: breadcrumbs(&path),
+        entries,
+        readme_html,
+        latest_commit,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn repo_blob(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, rest)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let (branch, path) = split_ref_path(&grepo, &rest);
+    if path.is_empty() {
+        return Err(AppError::bad("missing file path"));
+    }
+    let (data, binary) = git::read_blob(&grepo, &branch, &path)
+        .map_err(|_| AppError::not_found())?;
+    let size = data.len();
+    let (content_html, is_markdown) = if binary {
+        (None, false)
+    } else {
+        let text = String::from_utf8_lossy(&data);
+        if path.ends_with(".md") || path.ends_with(".MD") {
+            (Some(render_markdown(&text)), true)
+        } else {
+            (Some(highlight(&path, &text)), false)
+        }
+    };
+    let branches = git::list_branches(&grepo).unwrap_or_default();
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(RepoBlobTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        branches,
+        branch,
+        path: path.clone(),
+        breadcrumbs: breadcrumbs(&path),
+        binary,
+        size,
+        content_html,
+        is_markdown,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct BranchQuery {
+    pub branch: Option<String>,
+}
+
+pub async fn repo_commits(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<BranchQuery>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let branch = q
+        .branch
+        .unwrap_or_else(|| repository.default_branch.clone());
+    let commits = git::list_commits(&grepo, &branch, 100)
+        .unwrap_or_default()
+        .into_iter()
+        .map(commit_view)
+        .collect();
+    let branches = git::list_branches(&grepo).unwrap_or_default();
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(RepoCommitsTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        branches,
+        branch,
+        commits,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn repo_commit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, id)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
+    let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
+    let diff_html = render_diff_html(&diff);
+    let message_html = linkify_commit_message(
+        &state.pool,
+        repository.id,
+        &owner,
+        &repo,
+        &commit.message,
+    )
+    .await;
+    Ok(RepoCommitTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        commit: commit_view(commit),
+        message_html,
+        diff_html,
+        clone_http: clone_urls(&state, &owner, &repo).0,
+        clone_ssh: clone_urls(&state, &owner, &repo).1,
+    })
+}
+
+pub async fn repo_diff(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, id)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
+    let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
+    let diff_html = render_diff_html(&diff);
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(RepoDiffTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        commit: commit_view(commit),
+        diff_html,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct ArchiveQuery {
+    #[serde(rename = "ref")]
+    pub ref_name: Option<String>,
+    /// Optional subtree path (directory or file) within the ref.
+    pub path: Option<String>,
+}
+
+pub async fn repo_archive_zip(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<ArchiveQuery>,
+) -> AppResult<Response> {
+    let (repository, _owner_user, _viewer, _access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let reference = q
+        .ref_name
+        .filter(|s| !s.is_empty())
+        .unwrap_or_else(|| repository.default_branch.clone());
+    let path = q
+        .path
+        .as_deref()
+        .map(|s| s.trim_matches('/'))
+        .filter(|s| !s.is_empty());
+    let bytes = git::archive_zip(
+        &state.config.repos_dir(),
+        &owner,
+        &repo,
+        &reference,
+        path,
+    )
+    .map_err(|e| AppError::bad(e.to_string()))?;
+    let ref_slug = reference.replace('/', "-");
+    let filename = match path {
+        Some(p) => {
+            let base = p.rsplit('/').next().unwrap_or(p);
+            format!("{repo}-{ref_slug}-{base}.zip")
+        }
+        None => format!("{repo}-{ref_slug}.zip"),
+    };
+    Ok(Response::builder()
+        .status(StatusCode::OK)
+        .header(CONTENT_TYPE, "application/zip")
+        .header(
+            CONTENT_DISPOSITION,
+            format!("attachment; filename=\"{filename}\""),
+        )
+        .body(Body::from(bytes))
+        .unwrap())
+}
+
+pub async fn repo_upload(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    mut multipart: Multipart,
+) -> AppResult<Response> {
+    let (repository, _owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let mut branch = repository.default_branch.clone();
+    let mut dir = String::new();
+    let mut message = String::from("Upload file");
+    let mut file_name = String::new();
+    let mut file_bytes: Option<bytes::Bytes> = None;
+
+    while let Some(field) = multipart
+        .next_field()
+        .await
+        .map_err(|e| AppError::bad(e.to_string()))?
+    {
+        let name = field.name().unwrap_or("").to_string();
+        match name.as_str() {
+            "branch" => {
+                branch = field
+                    .text()
+                    .await
+                    .map_err(|e| AppError::bad(e.to_string()))?;
+            }
+            "path" => {
+                dir = field
+                    .text()
+                    .await
+                    .map_err(|e| AppError::bad(e.to_string()))?
+                    .trim_matches('/')
+                    .to_string();
+            }
+            "message" => {
+                let m = field
+                    .text()
+                    .await
+                    .map_err(|e| AppError::bad(e.to_string()))?;
+                if !m.trim().is_empty() {
+                    message = m;
+                }
+            }
+            "file" => {
+                file_name = field.file_name().unwrap_or("").to_string();
+                if file_name.is_empty() {
+                    let _ = field.bytes().await;
+                    continue;
+                }
+                // sanitize filename
+                file_name = file_name
+                    .rsplit(['/', '\\'])
+                    .next()
+                    .unwrap_or(&file_name)
+                    .to_string();
+                if file_name.contains("..") || file_name.is_empty() {
+                    return Err(AppError::bad("invalid filename"));
+                }
+                file_bytes = Some(
+                    field
+                        .bytes()
+                        .await
+                        .map_err(|e| AppError::bad(e.to_string()))?,
+                );
+            }
+            _ => {
+                let _ = field.bytes().await;
+            }
+        }
+    }
+
+    let data = file_bytes.ok_or_else(|| AppError::bad("file required"))?;
+    if data.len() > 50 * 1024 * 1024 {
+        return Err(AppError::bad("file too large (max 50MB)"));
+    }
+    let dest = if dir.is_empty() {
+        file_name.clone()
+    } else {
+        format!("{dir}/{file_name}")
+    };
+    let author = if user.display_name.is_empty() {
+        user.username.clone()
+    } else {
+        user.display_name.clone()
+    };
+    git::commit_file(
+        &state.config.repos_dir(),
+        &owner,
+        &repo,
+        &branch,
+        &dest,
+        &data,
+        &author,
+        &user.email,
+        &message,
+    )
+    .map_err(|e| AppError::bad(e.to_string()))?;
+
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "push",
+        "uploaded file",
+        serde_json::json!({ "path": dest }),
+    )
+    .await?;
+
+    let redirect_path = if dir.is_empty() {
+        format!("/{owner}/{repo}/tree/{branch}")
+    } else {
+        format!("/{owner}/{repo}/tree/{branch}/{dir}")
+    };
+    Ok(redirect_see_other(&redirect_path))
+}
+
+pub async fn repo_branches(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    let names = git::list_branches(&grepo).unwrap_or_default();
+    let default_branch = repository.default_branch.clone();
+    let open_pulls = queries::list_pulls(&state.pool, repository.id, Some("open"))
+        .await
+        .unwrap_or_default();
+    let branches: Vec<BranchRow> = names
+        .into_iter()
+        .map(|name| {
+            let is_default = name == default_branch;
+            let (ahead, behind) = if is_default {
+                (0usize, 0usize)
+            } else {
+                git::ahead_behind(&grepo, &name, &default_branch).unwrap_or((0, 0))
+            };
+            let pull_number = open_pulls
+                .iter()
+                .find(|p| p.source_branch == name)
+                .map(|p| p.number);
+            let updated = git::list_commits(&grepo, &name, 1)
+                .ok()
+                .and_then(|mut v| v.pop())
+                .map(|c| c.time.to_string())
+                .unwrap_or_default();
+            BranchRow {
+                name,
+                is_default,
+                updated,
+                ahead,
+                behind,
+                pull_number,
+            }
+        })
+        .collect();
+    let release_tags: std::collections::HashSet<String> =
+        queries::list_releases(&state.pool, repository.id)
+            .await
+            .unwrap_or_default()
+            .into_iter()
+            .map(|r| r.tag_name)
+            .collect();
+    let tags: Vec<TagRow> = git::list_tags(&grepo)
+        .unwrap_or_default()
+        .into_iter()
+        .map(|t| TagRow {
+            has_release: release_tags.contains(&t.name),
+            name: t.name,
+            short_id: t.short_id,
+            target: t.target,
+            message: t.message,
+            updated: t.time.to_string(),
+        })
+        .collect();
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(RepoBranchesTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        branches,
+        tags,
+        clone_http,
+        clone_ssh,
+        error: None,
+    })
+}
+
+fn commit_view(c: git::CommitInfo) -> CommitView {
+    CommitView {
+        id: c.id,
+        short_id: c.short_id,
+        message: c.message,
+        author: c.author,
+        email: c.email,
+        time: c.time,
+    }
+}
+
+// ── issues ───────────────────────────────────────────────────────────────────
+
+#[derive(Deserialize)]
+pub struct StateFilter {
+    pub state: Option<String>,
+}
+
+pub async fn issues_list(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<StateFilter>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.issues_enabled {
+        return Err(AppError::not_found());
+    }
+    // Default to open-only (GitHub-style) when no state query param is set.
+    let filter = match q.state.as_deref() {
+        Some("closed") => "closed",
+        _ => "open",
+    };
+    let issues = queries::list_issues(&state.pool, repository.id, Some(filter)).await?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(IssuesListTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        issues,
+        state_filter: filter.to_string(),
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn issue_new(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.issues_enabled {
+        return Err(AppError::not_found());
+    }
+    let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(IssueNewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        error: None,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct IssueCreateForm {
+    pub title: String,
+    pub body: Option<String>,
+}
+
+pub async fn issue_create(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<IssueCreateForm>,
+) -> AppResult<Response> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.issues_enabled {
+        return Err(AppError::not_found());
+    }
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let title = form.title.trim();
+    if title.is_empty() {
+        let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+        return Ok(IssueNewTemplate {
+            viewer: Some(user),
+            owner: owner_user,
+            repo: repository,
+            access,
+            error: Some("title required".into()),
+            clone_http,
+            clone_ssh,
+        }
+        .into_response());
+    }
+    let body = form.body.unwrap_or_default();
+    let number = queries::next_issue_number(&state.pool, repository.id).await?;
+    let issue =
+        queries::create_issue(&state.pool, repository.id, user.id, number, title, &body).await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "issue.open",
+        &format!("opened issue #{number}"),
+        serde_json::json!({ "number": number }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/issues/{}",
+        issue.number
+    )))
+}
+
+pub async fn issue_view(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.issues_enabled {
+        return Err(AppError::not_found());
+    }
+    let issue = queries::get_issue(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let author = queries::get_user_by_id(&state.pool, issue.author_id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let comments = comment_views(
+        &state.pool,
+        queries::list_issue_comments(&state.pool, issue.id).await?,
+        viewer.as_ref().map(|u| u.id),
+    )
+    .await?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(IssueViewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        author_avatar: avatar_url_for(&author),
+        body_html: render_markdown(&issue.body),
+        issue,
+        author,
+        comments,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct CommentForm {
+    pub body: String,
+}
+
+pub async fn issue_comment(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+    Form(form): Form<CommentForm>,
+) -> AppResult<Response> {
+    let (repository, _owner_user, viewer, _access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let issue = queries::get_issue(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let body = form.body.trim();
+    if body.is_empty() {
+        return Err(AppError::bad("empty comment"));
+    }
+    queries::add_comment(
+        &state.pool,
+        repository.id,
+        user.id,
+        Some(issue.id),
+        None,
+        body,
+    )
+    .await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "issue.comment",
+        &format!("commented on issue #{number}"),
+        serde_json::json!({ "number": number }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/issues/{number}"
+    )))
+}
+
+pub async fn issue_close(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let issue = queries::get_issue(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if !(access.can_write() || issue.author_id == user.id) {
+        return Err(AppError::forbidden());
+    }
+    queries::set_issue_state(&state.pool, issue.id, "closed").await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/issues/{number}"
+    )))
+}
+
+pub async fn issue_reopen(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let issue = queries::get_issue(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if !(access.can_write() || issue.author_id == user.id) {
+        return Err(AppError::forbidden());
+    }
+    queries::set_issue_state(&state.pool, issue.id, "open").await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/issues/{number}"
+    )))
+}
+
+// ── pull requests ────────────────────────────────────────────────────────────
+
+pub async fn pulls_list(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<StateFilter>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.pulls_enabled {
+        return Err(AppError::not_found());
+    }
+    let filter = q
+        .state
+        .as_deref()
+        .filter(|s| *s == "open" || *s == "closed" || *s == "merged");
+    let pulls = queries::list_pulls(&state.pool, repository.id, filter).await?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(PullsListTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        pulls,
+        state_filter: filter.unwrap_or("open").to_string(),
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn pull_new(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.pulls_enabled {
+        return Err(AppError::not_found());
+    }
+    let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    let branches = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .ok()
+        .and_then(|g| git::list_branches(&g).ok())
+        .unwrap_or_default();
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(PullNewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        branches,
+        error: None,
+        clone_http,
+        clone_ssh,
+        upstream: None,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct PullCreateForm {
+    pub title: String,
+    pub body: Option<String>,
+    pub source_branch: String,
+    pub target_branch: String,
+}
+
+pub async fn pull_create(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<PullCreateForm>,
+) -> AppResult<Response> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.pulls_enabled {
+        return Err(AppError::not_found());
+    }
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let title = form.title.trim();
+    let source = form.source_branch.trim();
+    let target = form.target_branch.trim();
+    if title.is_empty() || source.is_empty() || target.is_empty() {
+        let branches = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+            .ok()
+            .and_then(|g| git::list_branches(&g).ok())
+            .unwrap_or_default();
+        let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+        return Ok(PullNewTemplate {
+            viewer: Some(user),
+            owner: owner_user,
+            repo: repository,
+            access,
+            branches,
+            error: Some("title, source, and target required".into()),
+            clone_http,
+            clone_ssh,
+            upstream: None,
+        }
+        .into_response());
+    }
+    if source == target {
+        return Err(AppError::bad("source and target must differ"));
+    }
+    let body = form.body.unwrap_or_default();
+    let number = queries::next_pull_number(&state.pool, repository.id).await?;
+    let pull = queries::create_pull(
+        &state.pool,
+        repository.id,
+        user.id,
+        number,
+        title,
+        &body,
+        source,
+        target,
+    )
+    .await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "pull.open",
+        &format!("opened pull #{number}"),
+        serde_json::json!({ "number": number }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/pulls/{}",
+        pull.number
+    )))
+}
+
+#[derive(Deserialize)]
+pub struct PullTabQuery {
+    pub tab: Option<String>,
+}
+
+pub async fn pull_view(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+    Query(q): Query<PullTabQuery>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.pulls_enabled {
+        return Err(AppError::not_found());
+    }
+    let pull = queries::get_pull(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let author = queries::get_user_by_id(&state.pool, pull.author_id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let comments = comment_views(
+        &state.pool,
+        queries::list_pull_comments(&state.pool, pull.id).await?,
+        viewer.as_ref().map(|u| u.id),
+    )
+    .await?;
+
+    let tab = match q.tab.as_deref() {
+        Some("commits") => "commits",
+        Some("files") => "files",
+        _ => "conversation",
+    }
+    .to_string();
+
+    let mut commits = Vec::new();
+    let mut diff_files = Vec::new();
+    if let Ok(g) = git::open_bare(&state.config.repos_dir(), &owner, &repo) {
+        commits = git::commits_between(&g, &pull.source_branch, &pull.target_branch, 100)
+            .unwrap_or_default()
+            .into_iter()
+            .map(commit_view)
+            .collect();
+        let diff =
+            git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default();
+        diff_files = parse_diff_files(&diff);
+    }
+
+    let can_merge = access.can_write() && pull.state == "open";
+    let mut merge_styles = Vec::new();
+    if repository.allow_merge {
+        merge_styles.push("merge".to_string());
+    }
+    if repository.allow_squash {
+        merge_styles.push("squash".to_string());
+    }
+    if repository.allow_rebase {
+        merge_styles.push("rebase".to_string());
+    }
+
+    let conversation_count = comments.len() + 1;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(PullViewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        author_avatar: avatar_url_for(&author),
+        body_html: render_markdown(&pull.body),
+        pull,
+        author,
+        comments,
+        commits,
+        conversation_count,
+        diff_files,
+        tab,
+        can_merge,
+        merge_styles,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn pull_comment(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+    Form(form): Form<CommentForm>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, _a) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let pull = queries::get_pull(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let body = form.body.trim();
+    if body.is_empty() {
+        return Err(AppError::bad("empty comment"));
+    }
+    queries::add_comment(
+        &state.pool,
+        repository.id,
+        user.id,
+        None,
+        Some(pull.id),
+        body,
+    )
+    .await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "pull.comment",
+        &format!("commented on pull #{number}"),
+        serde_json::json!({ "number": number }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
+}
+
+#[derive(Deserialize)]
+pub struct MergeForm {
+    pub style: Option<String>,
+}
+
+pub async fn pull_merge(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+    Form(form): Form<MergeForm>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    if repository.archived {
+        return Err(AppError::forbidden());
+    }
+    let pull = queries::get_pull(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if pull.state != "open" {
+        return Err(AppError::bad("pull is not open"));
+    }
+
+    let style = form
+        .style
+        .as_deref()
+        .unwrap_or(repository.default_merge_style.as_str());
+    let allowed = match style {
+        "squash" => repository.allow_squash,
+        "rebase" => repository.allow_rebase,
+        _ => repository.allow_merge,
+    };
+    if !allowed {
+        return Err(AppError::bad("merge style not allowed"));
+    }
+
+    let message = format!("Merge pull request #{number} from {}", pull.source_branch);
+    let author = if user.display_name.is_empty() {
+        user.username.as_str()
+    } else {
+        user.display_name.as_str()
+    };
+    let merge_commit = git::merge_branches(
+        &state.config.repos_dir(),
+        &owner,
+        &repo,
+        &pull.source_branch,
+        &pull.target_branch,
+        style,
+        &message,
+        author,
+        &user.email,
+    )
+    .map_err(|e| AppError::bad(format!("merge failed: {e}")))?;
+
+    queries::set_pull_state(&state.pool, pull.id, "merged", Some(&merge_commit)).await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "pull.merge",
+        &format!("merged pull #{number}"),
+        serde_json::json!({ "number": number, "commit": merge_commit }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
+}
+
+pub async fn pull_close(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let pull = queries::get_pull(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if !(access.can_write() || pull.author_id == user.id) {
+        return Err(AppError::forbidden());
+    }
+    if pull.state != "open" {
+        return Err(AppError::bad("pull is not open"));
+    }
+    queries::set_pull_state(&state.pool, pull.id, "closed", None).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
+}
+
+// ── releases ─────────────────────────────────────────────────────────────────
+
+fn format_bytes(n: i64) -> String {
+    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
+    let mut v = n.max(0) as f64;
+    let mut i = 0usize;
+    while v >= 1024.0 && i < UNITS.len() - 1 {
+        v /= 1024.0;
+        i += 1;
+    }
+    if i == 0 {
+        format!("{} {}", n, UNITS[i])
+    } else {
+        format!("{v:.1} {}", UNITS[i])
+    }
+}
+
+fn asset_views(assets: Vec<crate::db::models::ReleaseAsset>) -> Vec<ReleaseAssetView> {
+    assets
+        .into_iter()
+        .map(|a| ReleaseAssetView {
+            id: a.id,
+            filename: a.filename,
+            size_label: format_bytes(a.size_bytes),
+            content_type: a.content_type,
+        })
+        .collect()
+}
+
+fn release_branch_choices(state: &AppState, owner: &str, repo: &str, default: &str) -> Vec<String> {
+    let mut branches = git::open_bare(&state.config.repos_dir(), owner, repo)
+        .ok()
+        .and_then(|g| git::list_branches(&g).ok())
+        .unwrap_or_default();
+    if branches.is_empty() {
+        branches.push(default.to_string());
+    }
+    branches
+}
+
+pub async fn releases_list(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    let mut releases = queries::list_releases(&state.pool, repository.id).await?;
+    if !access.can_write() {
+        releases.retain(|r| !r.is_draft);
+    }
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(ReleasesListTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        releases,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn release_new(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let target = repository.default_branch.clone();
+    let branches = release_branch_choices(&state, &owner, &repo, &target);
+    Ok(ReleaseNewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        error: None,
+        clone_http,
+        clone_ssh,
+        tag_name: String::new(),
+        title: String::new(),
+        body: String::new(),
+        target,
+        is_prerelease: false,
+        is_draft: false,
+        branches,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct ReleaseCreateForm {
+    pub tag_name: String,
+    pub title: String,
+    pub body: Option<String>,
+    pub target: Option<String>,
+    pub is_prerelease: Option<String>,
+    pub is_draft: Option<String>,
+}
+
+pub async fn release_create(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<ReleaseCreateForm>,
+) -> AppResult<Response> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let tag = form.tag_name.trim().to_string();
+    let title = form.title.trim().to_string();
+    let body = form.body.clone().unwrap_or_default();
+    let target = form
+        .target
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .unwrap_or(repository.default_branch.as_str())
+        .to_string();
+    let is_prerelease = checkbox(&form.is_prerelease);
+    let is_draft = checkbox(&form.is_draft);
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch);
+
+    let redisplay = |err: String| {
+        ReleaseNewTemplate {
+            viewer: Some(user.clone()),
+            owner: owner_user.clone(),
+            repo: repository.clone(),
+            access,
+            error: Some(err),
+            clone_http: clone_http.clone(),
+            clone_ssh: clone_ssh.clone(),
+            tag_name: tag.clone(),
+            title: title.clone(),
+            body: body.clone(),
+            target: target.clone(),
+            is_prerelease,
+            is_draft,
+            branches: branches.clone(),
+        }
+        .into_response()
+    };
+
+    if tag.is_empty() || title.is_empty() {
+        return Ok(redisplay("tag and title required".into()));
+    }
+    if queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .is_some()
+    {
+        return Ok(redisplay("a release already exists for this tag".into()));
+    }
+
+    let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+    if let Ok(grepo) = git::open_bare(&state.config.repos_dir(), &owner, &repo) {
+        if !git::tag_exists(&grepo, &tag) {
+            if let Err(e) = git::create_tag_at(&bare, &tag, &target, &title) {
+                return Ok(redisplay(format!("could not create tag: {e}")));
+            }
+        }
+    } else if let Err(e) = git::create_tag_at(&bare, &tag, &target, &title) {
+        return Ok(redisplay(format!("could not create tag: {e}")));
+    }
+
+    let release = queries::create_release(
+        &state.pool,
+        repository.id,
+        user.id,
+        &tag,
+        &title,
+        &body,
+        is_prerelease,
+        is_draft,
+    )
+    .await?;
+    let action = if is_draft { "drafted" } else { "published" };
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "release.create",
+        &format!("{action} release {tag}"),
+        serde_json::json!({ "tag": tag }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/releases/{}",
+        release.tag_name
+    )))
+}
+
+pub async fn release_view(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if release.is_draft && !access.can_write() {
+        return Err(AppError::not_found());
+    }
+    let assets = asset_views(queries::list_release_assets(&state.pool, release.id).await?);
+    let author = queries::get_user_by_id(&state.pool, release.author_id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let (tag_short, tag_target) = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .ok()
+        .and_then(|g| {
+            git::list_tags(&g)
+                .ok()?
+                .into_iter()
+                .find(|t| t.name == tag)
+                .map(|t| (t.short_id, t.target))
+        })
+        .unwrap_or_else(|| (tag.clone(), String::new()));
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    Ok(ReleaseViewTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        author_avatar: avatar_url_for(&author),
+        body_html: render_markdown(&release.body),
+        release,
+        author,
+        assets,
+        tag_short,
+        tag_target,
+        clone_http,
+        clone_ssh,
+    })
+}
+
+pub async fn release_edit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let target = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .ok()
+        .and_then(|g| {
+            git::list_tags(&g)
+                .ok()?
+                .into_iter()
+                .find(|t| t.name == tag)
+                .map(|t| t.target)
+        })
+        .unwrap_or_else(|| repository.default_branch.clone());
+    let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch);
+    Ok(ReleaseEditTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        release,
+        error: None,
+        clone_http,
+        clone_ssh,
+        target,
+        branches,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct ReleaseUpdateForm {
+    pub tag_name: String,
+    pub title: String,
+    pub body: Option<String>,
+    pub target: Option<String>,
+    pub is_prerelease: Option<String>,
+    pub is_draft: Option<String>,
+}
+
+pub async fn release_update(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+    Form(form): Form<ReleaseUpdateForm>,
+) -> AppResult<Response> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+
+    let new_tag = form.tag_name.trim().to_string();
+    let title = form.title.trim().to_string();
+    let body = form.body.clone().unwrap_or_default();
+    let is_prerelease = checkbox(&form.is_prerelease);
+    let is_draft = checkbox(&form.is_draft);
+    let target = form
+        .target
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .map(|s| s.to_string());
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch);
+
+    if new_tag.is_empty() || title.is_empty() {
+        let mut release = release;
+        release.tag_name = new_tag;
+        release.title = title;
+        release.body = body;
+        release.is_prerelease = is_prerelease;
+        release.is_draft = is_draft;
+        return Ok(ReleaseEditTemplate {
+            viewer: Some(user),
+            owner: owner_user,
+            repo: repository,
+            access,
+            release,
+            error: Some("tag and title required".into()),
+            clone_http,
+            clone_ssh,
+            target: target.unwrap_or_default(),
+            branches,
+        }
+        .into_response());
+    }
+
+    if new_tag != tag {
+        if queries::get_release(&state.pool, repository.id, &new_tag)
+            .await?
+            .is_some()
+        {
+            return Err(AppError::bad("a release already exists for that tag"));
+        }
+        if let Ok(grepo) = git::open_bare(&state.config.repos_dir(), &owner, &repo) {
+            if git::tag_exists(&grepo, &tag) {
+                git::rename_tag(&grepo, &tag, &new_tag)
+                    .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?;
+            }
+        }
+        // Move on-disk assets directory if present
+        let old_dir = state
+            .config
+            .releases_dir()
+            .join(repository.id.to_string())
+            .join(&tag);
+        let new_dir = state
+            .config
+            .releases_dir()
+            .join(repository.id.to_string())
+            .join(&new_tag);
+        if old_dir.is_dir() && !new_dir.exists() {
+            let _ = std::fs::rename(&old_dir, &new_dir);
+        }
+        let old_prefix = format!("{}/{}/", repository.id, tag);
+        let new_prefix = format!("{}/{}/", repository.id, new_tag);
+        queries::rewrite_asset_paths_for_tag(&state.pool, release.id, &old_prefix, &new_prefix)
+            .await?;
+    }
+
+    if let Some(ref tgt) = target {
+        let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+        let _ = git::retarget_tag(&bare, &new_tag, tgt, &title);
+    }
+
+    let updated = queries::update_release(
+        &state.pool,
+        release.id,
+        &new_tag,
+        &title,
+        &body,
+        is_prerelease,
+        is_draft,
+    )
+    .await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "release.update",
+        &format!("updated release {}", updated.tag_name),
+        serde_json::json!({ "tag": updated.tag_name }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/releases/{}",
+        updated.tag_name
+    )))
+}
+
+pub async fn release_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !repository.releases_enabled {
+        return Err(AppError::not_found());
+    }
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let assets = queries::list_release_assets(&state.pool, release.id).await?;
+    for a in &assets {
+        let path = state.config.releases_dir().join(&a.stored_path);
+        let _ = std::fs::remove_file(path);
+    }
+    let dir = state
+        .config
+        .releases_dir()
+        .join(repository.id.to_string())
+        .join(&tag);
+    let _ = std::fs::remove_dir_all(dir);
+    queries::delete_release(&state.pool, release.id).await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "release.delete",
+        &format!("deleted release {tag}"),
+        serde_json::json!({ "tag": tag }),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/releases")))
+}
+
+pub async fn release_upload(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+    mut multipart: Multipart,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+
+    let dir = state
+        .config
+        .releases_dir()
+        .join(repository.id.to_string())
+        .join(&tag);
+    std::fs::create_dir_all(&dir)?;
+
+    while let Some(field) = multipart
+        .next_field()
+        .await
+        .map_err(|e| AppError::bad(e.to_string()))?
+    {
+        let name = field.name().unwrap_or("").to_string();
+        if name != "asset" && name != "file" {
+            let _ = field.bytes().await;
+            continue;
+        }
+        let filename = field
+            .file_name()
+            .unwrap_or("asset.bin")
+            .replace(['/', '\\'], "_");
+        if filename.is_empty() {
+            continue;
+        }
+        let content_type = field
+            .content_type()
+            .unwrap_or("application/octet-stream")
+            .to_string();
+        let data = field
+            .bytes()
+            .await
+            .map_err(|e| AppError::bad(e.to_string()))?;
+        let stored_name = format!("{}-{}", Uuid::new_v4(), filename);
+        let path = dir.join(&stored_name);
+        std::fs::write(&path, &data)?;
+        let rel = format!("{}/{}/{}", repository.id, tag, stored_name);
+        queries::add_release_asset(
+            &state.pool,
+            release.id,
+            &filename,
+            &rel,
+            data.len() as i64,
+            &content_type,
+        )
+        .await?;
+    }
+
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/releases/{tag}"
+    )))
+}
+
+pub async fn asset_download(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, id)): Path<(String, String, Uuid)>,
+) -> AppResult<Response> {
+    let (_repository, _o, _viewer, _access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let asset = queries::get_asset(&state.pool, id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let path = state.config.releases_dir().join(&asset.stored_path);
+    if !path.is_file() {
+        // also try absolute stored path
+        let alt = PathBuf::from(&asset.stored_path);
+        if alt.is_file() {
+            return serve_file(&alt, &asset.filename, &asset.content_type);
+        }
+        return Err(AppError::not_found());
+    }
+    serve_file(&path, &asset.filename, &asset.content_type)
+}
+
+pub async fn asset_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag, id)): Path<(String, String, String, Uuid)>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let release = queries::get_release(&state.pool, repository.id, &tag)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    let asset = queries::delete_release_asset(&state.pool, id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if asset.release_id != release.id {
+        return Err(AppError::not_found());
+    }
+    let path = state.config.releases_dir().join(&asset.stored_path);
+    let _ = std::fs::remove_file(path);
+    Ok(redirect_see_other(&format!(
+        "/{owner}/{repo}/releases/{tag}"
+    )))
+}
+
+fn serve_file(path: &std::path::Path, filename: &str, content_type: &str) -> AppResult<Response> {
+    let data = std::fs::read(path)?;
+    let disp = format!("attachment; filename=\"{}\"", filename.replace('"', ""));
+    Ok(Response::builder()
+        .status(StatusCode::OK)
+        .header(CONTENT_TYPE, content_type)
+        .header(CONTENT_DISPOSITION, disp)
+        .body(Body::from(data))
+        .unwrap())
+}
+
+// ── repo settings ────────────────────────────────────────────────────────────
+
+pub async fn repo_settings(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<impl IntoResponse> {
+    let (repository, owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    let collaborators = queries::list_collaborators(&state.pool, repository.id).await?;
+    let collab_views: Vec<CollaboratorView> = collaborators
+        .into_iter()
+        .map(|(c, u)| {
+            let avatar_url = avatar_url_for(&u);
+            CollaboratorView {
+                user: u,
+                role: c.role,
+                avatar_url,
+            }
+        })
+        .collect();
+    let mut branches = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .ok()
+        .and_then(|g| git::list_branches(&g).ok())
+        .unwrap_or_default();
+    if !branches.is_empty() && !branches.iter().any(|b| b == &repository.default_branch) {
+        branches.insert(0, repository.default_branch.clone());
+    }
+    let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
+    let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?;
+    let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
+    Ok(RepoSettingsTemplate {
+        viewer,
+        owner: owner_user,
+        repo: repository,
+        access,
+        collaborators: collab_views,
+        branches,
+        clone_http,
+        clone_ssh,
+        branch_rules,
+        is_site_admin,
+        error: None,
+    })
+}
+
+#[derive(Deserialize)]
+pub struct RepoSettingsForm {
+    pub description: Option<String>,
+    pub visibility: Option<String>,
+    pub default_branch: Option<String>,
+    pub issues_enabled: Option<String>,
+    pub pulls_enabled: Option<String>,
+    pub releases_enabled: Option<String>,
+    pub allow_merge: Option<String>,
+    pub allow_squash: Option<String>,
+    pub allow_rebase: Option<String>,
+    pub default_merge_style: Option<String>,
+    pub protect_default_branch: Option<String>,
+    pub protect_block_force_push: Option<String>,
+}
+
+pub async fn repo_settings_save(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<RepoSettingsForm>,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    let visibility = match form.visibility.as_deref() {
+        Some("private") => "private",
+        _ => "public",
+    };
+    let merge_style = match form.default_merge_style.as_deref() {
+        Some("squash") => "squash",
+        Some("rebase") => "rebase",
+        _ => "merge",
+    };
+    let default_branch = form
+        .default_branch
+        .as_deref()
+        .unwrap_or(&repository.default_branch)
+        .trim()
+        .to_string();
+    if default_branch.is_empty() {
+        return Err(AppError::bad("default branch required"));
+    }
+    queries::update_repo_settings(
+        &state.pool,
+        repository.id,
+        form.description.as_deref().unwrap_or(""),
+        visibility,
+        &default_branch,
+        checkbox(&form.issues_enabled),
+        checkbox(&form.pulls_enabled),
+        checkbox(&form.releases_enabled),
+        checkbox(&form.allow_merge),
+        checkbox(&form.allow_squash),
+        checkbox(&form.allow_rebase),
+        merge_style,
+        checkbox(&form.protect_default_branch),
+        checkbox(&form.protect_block_force_push),
+    )
+    .await?;
+    let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+    if let Ok(g) = git::open_bare(&state.config.repos_dir(), &owner, &repo) {
+        if git::resolve_ref(&g, &default_branch).is_ok() {
+            let _ = git::set_head_branch(&g, &default_branch);
+        }
+    }
+    git::sync_branch_protection(
+        &bare,
+        &default_branch,
+        checkbox(&form.protect_default_branch),
+        checkbox(&form.protect_block_force_push),
+    )?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+#[derive(Deserialize)]
+pub struct CollabAddForm {
+    pub username: String,
+    pub role: Option<String>,
+}
+
+pub async fn collab_add(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<CollabAddForm>,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    let username = form.username.trim();
+    let role = match form.role.as_deref() {
+        Some("admin") => "admin",
+        Some("read") => "read",
+        _ => "write",
+    };
+    let target = queries::get_user_by_username(&state.pool, username)
+        .await?
+        .ok_or_else(|| AppError::bad("user not found"))?;
+    if target.id == repository.owner_id {
+        return Err(AppError::bad("owner is already the owner"));
+    }
+    queries::add_collaborator(&state.pool, repository.id, target.id, role).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+pub async fn collab_remove(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, user_id)): Path<(String, String, Uuid)>,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    queries::remove_collaborator(&state.pool, repository.id, user_id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+#[derive(Deserialize)]
+pub struct ArchiveForm {
+    pub archived: Option<String>,
+}
+
+pub async fn repo_archive(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<ArchiveForm>,
+) -> AppResult<Response> {
+    let (repository, _o, _viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_owner() {
+        return Err(AppError::forbidden());
+    }
+    let archived = match form.archived.as_deref() {
+        Some("false") | Some("0") | Some("unarchive") => false,
+        _ => !repository.archived,
+    };
+    queries::set_archived(&state.pool, repository.id, archived).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+#[derive(Deserialize)]
+pub struct DeleteForm {
+    pub confirm: Option<String>,
+}
+
+pub async fn repo_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<DeleteForm>,
+) -> AppResult<Response> {
+    let (repository, _owner_user, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let is_admin = user.is_site_admin;
+    if !access.can_owner() && !is_admin {
+        return Err(AppError::forbidden());
+    }
+    let confirm = form.confirm.unwrap_or_default();
+    if confirm != repository.name && confirm != format!("{owner}/{repo}") {
+        return Err(AppError::bad("type the repository name to confirm delete"));
+    }
+    queries::delete_repo(&state.pool, repository.id).await?;
+    let _ = git::remove_bare(&state.config.repos_dir(), &owner, &repo);
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        None,
+        "repo.delete",
+        "deleted repository",
+        serde_json::json!({}),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{}", user.username)))
+}
+
+#[derive(Deserialize)]
+pub struct TransferForm {
+    pub new_owner: String,
+}
+
+pub async fn repo_transfer(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<TransferForm>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _user = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_owner() {
+        return Err(AppError::forbidden());
+    }
+    let new_name = form.new_owner.trim();
+    let new_owner = queries::get_user_by_username(&state.pool, new_name)
+        .await?
+        .ok_or_else(|| AppError::bad("user not found"))?;
+    if new_owner.id == repository.owner_id {
+        return Err(AppError::bad("already owned by that user"));
+    }
+    if queries::get_repo(&state.pool, &new_owner.username, &repo)
+        .await?
+        .is_some()
+    {
+        return Err(AppError::bad("target already has a repo with that name"));
+    }
+
+    let old_path = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+    let new_path = git::bare_path(&state.config.repos_dir(), &new_owner.username, &repo);
+    if old_path.exists() {
+        if let Some(parent) = new_path.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        std::fs::rename(&old_path, &new_path).map_err(|e| {
+            AppError::internal(format!("failed to move repository on disk: {e}"))
+        })?;
+    }
+
+    queries::transfer_repo(&state.pool, repository.id, new_owner.id).await?;
+    Ok(redirect_see_other(&format!(
+        "/{}/{repo}",
+        new_owner.username
+    )))
+}
+
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
new file mode 100644
index 0000000..8a3865f
--- /dev/null
+++ b/src/web/routes_extra.rs
@@ -0,0 +1,809 @@
+//! Social, account, branch, raw, and admin helpers.
+
+use super::routes::{
+    avatar_url_for, load_repo_context, redirect_see_other, require_login, AppError, AppResult,
+};
+use crate::auth::{self, clear_session_cookie, hash_token, token_from_headers};
+use crate::db::queries;
+use crate::git;
+use crate::state::AppState;
+use crate::web::templates::*;
+use axum::extract::{Form, Path, State};
+use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE};
+use axum::http::{HeaderMap, HeaderValue, StatusCode};
+use axum::response::{IntoResponse, Response};
+use axum::body::Body;
+use serde::Deserialize;
+use uuid::Uuid;
+
+fn checkbox(v: &Option<String>) -> bool {
+    matches!(
+        v.as_deref(),
+        Some("on") | Some("true") | Some("1") | Some("yes")
+    )
+}
+
+pub async fn repo_raw(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, rest)): Path<(String, String, String)>,
+) -> AppResult<Response> {
+    let (_repository, _owner_user, _viewer, _access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
+        .map_err(|_| AppError::not_found())?;
+    // reuse split via rest: branch/path
+    let rest = rest.trim_matches('/');
+    let parts: Vec<&str> = rest.split('/').collect();
+    if parts.len() < 2 {
+        return Err(AppError::bad("missing path"));
+    }
+    // Prefer longest matching ref prefix
+    let (branch, path) = {
+        let mut found = None;
+        for i in (1..parts.len()).rev() {
+            let reference = parts[..i].join("/");
+            if git::resolve_ref(&grepo, &reference).is_ok() {
+                found = Some((reference, parts[i..].join("/")));
+                break;
+            }
+        }
+        found.unwrap_or_else(|| (parts[0].to_string(), parts[1..].join("/")))
+    };
+    if path.is_empty() {
+        return Err(AppError::bad("missing file path"));
+    }
+    let (data, _binary) = git::read_blob(&grepo, &branch, &path).map_err(|_| AppError::not_found())?;
+    let filename = path.rsplit('/').next().unwrap_or("file");
+    let ct = mime_guess::from_path(filename)
+        .first_or_octet_stream()
+        .to_string();
+    let disp = format!("attachment; filename=\"{filename}\"");
+    Ok((
+        [
+            (CONTENT_TYPE, HeaderValue::from_str(&ct).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"))),
+            (
+                CONTENT_DISPOSITION,
+                HeaderValue::from_str(&disp).unwrap_or_else(|_| HeaderValue::from_static("attachment")),
+            ),
+        ],
+        Body::from(data),
+    )
+        .into_response())
+}
+
+pub async fn repo_star(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let (repository, _, _, _) = load_repo_context(&state, &owner, &repo, &headers).await?;
+    queries::toggle_star(&state.pool, repository.id, user.id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}")))
+}
+
+pub async fn repo_watch(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let (repository, _, _, _) = load_repo_context(&state, &owner, &repo, &headers).await?;
+    queries::toggle_watch(&state.pool, repository.id, user.id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}")))
+}
+
+pub async fn repo_fork(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let (repository, owner_user, _, _) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    if owner_user.id == user.id {
+        return Err(AppError::bad("cannot fork your own repository"));
+    }
+    if let Some(existing) =
+        queries::get_fork_of_user(&state.pool, repository.id, user.id).await?
+    {
+        return Ok(redirect_see_other(&format!(
+            "/{}/{}",
+            user.username, existing.name
+        )));
+    }
+    let name = repository.name.clone();
+    if queries::get_repo(&state.pool, &user.username, &name)
+        .await?
+        .is_some()
+    {
+        return Err(AppError::bad(
+            "you already have a repository with this name",
+        ));
+    }
+    let src = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+    let dest = git::bare_path(&state.config.repos_dir(), &user.username, &name);
+    git::clone_bare(&src, &dest)?;
+    let forked = queries::create_fork(
+        &state.pool,
+        user.id,
+        &name,
+        &repository.description,
+        &repository.visibility,
+        repository.id,
+    )
+    .await?;
+    let _ = forked;
+    Ok(redirect_see_other(&format!("/{}/{}", user.username, name)))
+}
+
+#[derive(Deserialize)]
+pub struct ReactForm {
+    pub emoji: String,
+}
+
+pub async fn comment_react(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, comment_id)): Path<(String, String, Uuid)>,
+    Form(form): Form<ReactForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let (_repository, _, _, access) = load_repo_context(&state, &owner, &repo, &headers).await?;
+    if !access.can_read() {
+        return Err(AppError::forbidden());
+    }
+    let allowed = [
+        "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes",
+    ];
+    if !allowed.contains(&form.emoji.as_str()) {
+        return Err(AppError::bad("invalid reaction"));
+    }
+    queries::toggle_reaction(&state.pool, comment_id, user.id, &form.emoji).await?;
+    let referer = headers
+        .get(axum::http::header::REFERER)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("/");
+    // Prefer relative path
+    let path = if let Ok(u) = url::Url::parse(referer) {
+        u.path().to_string()
+    } else if referer.starts_with('/') {
+        referer.to_string()
+    } else {
+        format!("/{owner}/{repo}")
+    };
+    Ok(redirect_see_other(&path))
+}
+
+#[derive(Deserialize)]
+pub struct BranchRuleForm {
+    pub pattern: String,
+    pub require_pr: Option<String>,
+    pub block_force_push: Option<String>,
+    pub allow_deletions: Option<String>,
+}
+
+pub async fn branch_rule_add(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<BranchRuleForm>,
+) -> AppResult<Response> {
+    let (_u, repository, access) = {
+        let (repository, _, viewer, access) =
+            load_repo_context(&state, &owner, &repo, &headers).await?;
+        let _ = viewer.ok_or_else(AppError::unauthorized)?;
+        if !access.can_admin() {
+            return Err(AppError::forbidden());
+        }
+        ((), repository, access)
+    };
+    let _ = access;
+    let pattern = form.pattern.trim();
+    if pattern.is_empty() {
+        return Err(AppError::bad("pattern required"));
+    }
+    queries::add_branch_rule(
+        &state.pool,
+        repository.id,
+        pattern,
+        checkbox(&form.require_pr),
+        checkbox(&form.block_force_push),
+        checkbox(&form.allow_deletions),
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+pub async fn branch_rule_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, id)): Path<(String, String, Uuid)>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_admin() {
+        return Err(AppError::forbidden());
+    }
+    queries::delete_branch_rule(&state.pool, id, repository.id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+#[derive(Deserialize)]
+pub struct RenameBranchForm {
+    pub new_name: String,
+}
+
+pub async fn branch_rename(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, branch)): Path<(String, String, String)>,
+    Form(form): Form<RenameBranchForm>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    if branch == repository.default_branch {
+        return Err(AppError::bad("cannot rename default branch here"));
+    }
+    let new_name = form.new_name.trim();
+    if new_name.is_empty() || new_name.contains('/') {
+        return Err(AppError::bad("invalid branch name"));
+    }
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
+    git::rename_branch(&grepo, &branch, new_name)?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
+}
+
+pub async fn branch_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, branch)): Path<(String, String, String)>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    if branch == repository.default_branch {
+        return Err(AppError::bad("cannot delete default branch"));
+    }
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
+    git::delete_branch(&grepo, &branch)?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
+}
+
+#[derive(Deserialize)]
+pub struct CreateTagForm {
+    pub name: String,
+    pub target: Option<String>,
+    pub message: Option<String>,
+}
+
+pub async fn tag_create(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<CreateTagForm>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let name = form.name.trim();
+    if name.is_empty() || name.contains("..") {
+        return Err(AppError::bad("invalid tag name"));
+    }
+    let target = form
+        .target
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .unwrap_or(repository.default_branch.as_str());
+    let message = form.message.unwrap_or_default();
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
+    if git::tag_exists(&grepo, name) {
+        return Err(AppError::bad("tag already exists"));
+    }
+    let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo);
+    git::create_tag_at(&bare, name, target, &message)
+        .map_err(|e| AppError::bad(format!("create tag failed: {e}")))?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
+}
+
+#[derive(Deserialize)]
+pub struct RenameTagForm {
+    pub new_name: String,
+}
+
+pub async fn tag_rename(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+    Form(form): Form<RenameTagForm>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let new_name = form.new_name.trim();
+    if new_name.is_empty() || new_name.contains("..") {
+        return Err(AppError::bad("invalid tag name"));
+    }
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
+    git::rename_tag(&grepo, &tag, new_name)
+        .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?;
+    if let Some(release) =
+        queries::rename_release_tag(&state.pool, repository.id, &tag, new_name).await?
+    {
+        let old_dir = state
+            .config
+            .releases_dir()
+            .join(repository.id.to_string())
+            .join(&tag);
+        let new_dir = state
+            .config
+            .releases_dir()
+            .join(repository.id.to_string())
+            .join(new_name);
+        if old_dir.is_dir() && !new_dir.exists() {
+            let _ = std::fs::rename(&old_dir, &new_dir);
+        }
+        let old_prefix = format!("{}/{}/", repository.id, tag);
+        let new_prefix = format!("{}/{}/", repository.id, new_name);
+        queries::rewrite_asset_paths_for_tag(&state.pool, release.id, &old_prefix, &new_prefix)
+            .await?;
+    }
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
+}
+
+pub async fn tag_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, tag)): Path<(String, String, String)>,
+) -> AppResult<Response> {
+    let (_repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _ = viewer.ok_or_else(AppError::unauthorized)?;
+    if !access.can_write() {
+        return Err(AppError::forbidden());
+    }
+    let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
+    git::delete_tag(&grepo, &tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
+}
+
+// ── account settings ─────────────────────────────────────────────────────────
+
+pub async fn account_settings(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let emails = queries::list_user_emails(&state.pool, user.id).await?;
+    let sessions_raw = queries::list_sessions(&state.pool, user.id).await?;
+    let current_hash = token_from_headers(&headers).map(|t| hash_token(&t));
+    let mut current_session_id = None;
+    let sessions = sessions_raw
+        .into_iter()
+        .map(|s| {
+            let is_current = current_hash
+                .as_ref()
+                .map(|h| h == &s.token_hash)
+                .unwrap_or(false);
+            if is_current {
+                current_session_id = Some(s.id);
+            }
+            SessionView {
+                id: s.id,
+                created_at: s.created_at,
+                last_seen_at: s.last_seen_at,
+                user_agent: s.user_agent,
+                ip_address: s.ip_address,
+                is_current,
+            }
+        })
+        .collect();
+    Ok(AccountSettingsTemplate {
+        viewer: Some(user.clone()),
+        user,
+        emails,
+        sessions,
+        current_session_id,
+        error: None,
+        message: None,
+    })
+}
+
+fn mfa_page(
+    user: crate::db::models::User,
+    mfa: Option<crate::db::models::UserMfa>,
+    recovery_codes: Option<Vec<String>>,
+    error: Option<String>,
+    message: Option<String>,
+) -> AppResult<MfaSettingsTemplate> {
+    let enabled = mfa.as_ref().is_some_and(|m| m.enabled);
+    let pending_secret = mfa
+        .as_ref()
+        .and_then(|m| m.pending_secret.clone())
+        .filter(|s| !s.is_empty());
+    let pending = !enabled && pending_secret.is_some();
+    let (secret, qr_data_uri) = if let Some(ref sec) = pending_secret {
+        let uri = crate::mfa::otpauth_uri(&user.username, sec, "kitgit");
+        let qr = crate::mfa::qr_svg_data_uri(&uri).ok();
+        (Some(sec.clone()), qr)
+    } else {
+        (None, None)
+    };
+    let recovery_remaining = mfa
+        .as_ref()
+        .map(|m| m.recovery_code_hashes.len())
+        .unwrap_or(0);
+    Ok(MfaSettingsTemplate {
+        viewer: Some(user.clone()),
+        user,
+        enabled,
+        pending,
+        secret,
+        qr_data_uri,
+        recovery_codes,
+        recovery_remaining,
+        error,
+        message,
+    })
+}
+
+pub async fn mfa_settings(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let mfa = queries::get_user_mfa(&state.pool, user.id).await?;
+    Ok(mfa_page(user, mfa, None, None, None)?)
+}
+
+pub async fn mfa_enroll(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    if queries::mfa_is_enabled(&state.pool, user.id).await? {
+        return Ok(redirect_see_other("/settings/mfa"));
+    }
+    let secret = crate::mfa::generate_totp_secret();
+    queries::mfa_start_enroll(&state.pool, user.id, &secret).await?;
+    Ok(redirect_see_other("/settings/mfa"))
+}
+
+#[derive(Deserialize)]
+pub struct MfaCodeForm {
+    pub code: String,
+}
+
+pub async fn mfa_confirm(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<MfaCodeForm>,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let mfa = queries::get_user_mfa(&state.pool, user.id)
+        .await?
+        .ok_or_else(|| AppError::bad("start enrollment first"))?;
+    let pending = mfa
+        .pending_secret
+        .as_deref()
+        .ok_or_else(|| AppError::bad("start enrollment first"))?;
+    if !crate::mfa::verify_totp(pending, &form.code) {
+        return Ok(mfa_page(
+            user,
+            Some(mfa),
+            None,
+            Some("invalid confirmation code".into()),
+            None,
+        )?
+        .into_response());
+    }
+    let codes = crate::mfa::generate_recovery_codes();
+    let hashes: Vec<String> = codes.iter().map(|c| crate::mfa::hash_recovery_code(c)).collect();
+    let secret = pending.to_string();
+    queries::mfa_confirm_enroll(&state.pool, user.id, &secret, &hashes).await?;
+    let mfa = queries::get_user_mfa(&state.pool, user.id).await?;
+    Ok(mfa_page(
+        user,
+        mfa,
+        Some(codes),
+        None,
+        Some("two-factor authentication enabled".into()),
+    )?
+    .into_response())
+}
+
+pub async fn mfa_disable(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<MfaCodeForm>,
+) -> AppResult<impl IntoResponse> {
+    let user = require_login(&state.auth, &headers).await?;
+    let mfa = queries::get_user_mfa(&state.pool, user.id)
+        .await?
+        .ok_or_else(|| AppError::bad("MFA is not enabled"))?;
+    if !mfa.enabled {
+        return Ok(redirect_see_other("/settings/mfa").into_response());
+    }
+    let secret = mfa
+        .totp_secret
+        .as_deref()
+        .ok_or_else(|| AppError::bad("MFA is not enabled"))?;
+    let ok = if crate::mfa::verify_totp(secret, &form.code) {
+        true
+    } else if let Some(idx) = crate::mfa::verify_recovery_code(&mfa.recovery_code_hashes, &form.code)
+    {
+        let mut hashes = mfa.recovery_code_hashes.clone();
+        hashes.remove(idx);
+        queries::mfa_set_recovery_hashes(&state.pool, user.id, &hashes).await?;
+        true
+    } else {
+        false
+    };
+    if !ok {
+        return Ok(mfa_page(
+            user,
+            Some(mfa),
+            None,
+            Some("invalid authentication code".into()),
+            None,
+        )?
+        .into_response());
+    }
+    queries::mfa_disable(&state.pool, user.id).await?;
+    Ok(redirect_see_other("/settings/mfa").into_response())
+}
+
+#[derive(Deserialize)]
+pub struct UsernameForm {
+    pub username: String,
+}
+
+pub async fn account_change_username(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<UsernameForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let username = form.username.trim().to_lowercase();
+    if !username
+        .chars()
+        .next()
+        .map(|c| c.is_ascii_alphanumeric())
+        .unwrap_or(false)
+        || username.len() > 39
+        || !username
+            .chars()
+            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
+    {
+        return Err(AppError::bad("invalid username"));
+    }
+    if username != user.username {
+        if queries::get_user_by_username(&state.pool, &username)
+            .await?
+            .is_some()
+        {
+            return Err(AppError::bad("username taken"));
+        }
+        // rename repos on disk
+        let old_root = state.config.repos_dir().join(&user.username);
+        let new_root = state.config.repos_dir().join(&username);
+        if old_root.exists() {
+            if new_root.exists() {
+                return Err(AppError::bad("cannot rename: target path exists"));
+            }
+            std::fs::rename(&old_root, &new_root)?;
+        }
+        queries::update_username(&state.pool, user.id, &username).await?;
+    }
+    Ok(redirect_see_other("/settings/account"))
+}
+
+#[derive(Deserialize)]
+pub struct PasswordForm {
+    pub current_password: String,
+    pub new_password: String,
+}
+
+pub async fn account_change_password(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<PasswordForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    if form.new_password.len() < 8 {
+        return Err(AppError::bad("password must be at least 8 characters"));
+    }
+    // Verify current password via Authentik (no session / MFA side effects).
+    auth::verify_password(&state.auth, &user.username, &form.current_password)
+        .await
+        .map_err(|_| AppError::bad("current password incorrect"))?;
+    let pk = auth::authentik_user_pk(&state.auth, &user.username)
+        .await
+        .map_err(|e| AppError::bad(crate::mfa::sanitize_user_error(&e.to_string())))?;
+    auth::authentik_set_password(&state.auth, pk, &form.new_password)
+        .await
+        .map_err(|e| AppError::bad(crate::mfa::sanitize_user_error(&e.to_string())))?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+#[derive(Deserialize)]
+pub struct PrivacyForm {
+    pub show_email: Option<String>,
+    pub vigilant_mode: Option<String>,
+}
+
+pub async fn account_privacy(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<PrivacyForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::update_privacy(
+        &state.pool,
+        user.id,
+        checkbox(&form.show_email),
+        checkbox(&form.vigilant_mode),
+    )
+    .await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+#[derive(Deserialize)]
+pub struct EmailForm {
+    pub email: String,
+}
+
+pub async fn account_add_email(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<EmailForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let email = form.email.trim();
+    if !email.contains('@') {
+        return Err(AppError::bad("invalid email"));
+    }
+    queries::add_user_email(&state.pool, user.id, email).await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+pub async fn account_delete_email(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::delete_user_email(&state.pool, id, user.id).await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+pub async fn account_revoke_session(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::delete_session_by_id(&state.pool, id, user.id).await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+pub async fn account_revoke_others(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let Some(token) = token_from_headers(&headers) else {
+        return Err(AppError::unauthorized());
+    };
+    queries::delete_other_sessions(&state.pool, user.id, &hash_token(&token)).await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+pub async fn account_export(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let data = queries::export_user_data(&state.pool, user.id).await?;
+    let body = serde_json::to_vec_pretty(&data).map_err(|e| AppError::internal(e))?;
+    Ok((
+        [
+            (CONTENT_TYPE, HeaderValue::from_static("application/json")),
+            (
+                CONTENT_DISPOSITION,
+                HeaderValue::from_static("attachment; filename=\"kitgit-export.json\""),
+            ),
+        ],
+        body,
+    )
+        .into_response())
+}
+
+#[derive(Deserialize)]
+pub struct DeleteAccountForm {
+    pub confirm: String,
+}
+
+pub async fn account_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<DeleteAccountForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    if form.confirm != user.username {
+        return Err(AppError::bad("type your username to confirm"));
+    }
+    // Remove repos on disk
+    let root = state.config.repos_dir().join(&user.username);
+    if root.exists() {
+        let _ = std::fs::remove_dir_all(&root);
+    }
+    queries::delete_user(&state.pool, user.id).await?;
+    Ok((
+        StatusCode::SEE_OTHER,
+        [
+            (
+                axum::http::header::LOCATION,
+                HeaderValue::from_static("/"),
+            ),
+            (SET_COOKIE, clear_session_cookie()),
+        ],
+    )
+        .into_response())
+}
+
+#[derive(Deserialize)]
+pub struct GpgForm {
+    pub name: String,
+    pub public_key: String,
+}
+
+pub async fn gpg_add(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<GpgForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let key = form.public_key.trim();
+    if !key.contains("BEGIN PGP PUBLIC KEY") {
+        return Err(AppError::bad("expected a PGP public key block"));
+    }
+    let mut hasher = sha2::Sha256::new();
+    use sha2::Digest;
+    hasher.update(key.as_bytes());
+    let fp = hex::encode(hasher.finalize());
+    queries::add_gpg_key(&state.pool, user.id, form.name.trim(), key, &fp).await?;
+    Ok(redirect_see_other("/settings/keys"))
+}
+
+pub async fn gpg_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::delete_gpg_key(&state.pool, id, user.id).await?;
+    Ok(redirect_see_other("/settings/keys"))
+}
+
+// silence unused import warning for avatar_url_for if not used
+#[allow(dead_code)]
+fn _use_avatar(u: &crate::db::models::User) -> String {
+    avatar_url_for(u)
+}
diff --git a/src/web/templates.rs b/src/web/templates.rs
new file mode 100644
index 0000000..1caa7ec
--- /dev/null
+++ b/src/web/templates.rs
@@ -0,0 +1,524 @@
+use crate::db::models::{
+    Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
+    UserEmail,
+};
+use askama::Template;
+use askama_web::WebTemplate;
+use chrono::{DateTime, Utc};
+use uuid::Uuid;
+
+// ── view models ──────────────────────────────────────────────────────────────
+
+pub struct ActivityRow {
+    pub kind: String,
+    /// Text before an optional issue/PR reference (e.g. `"commented on "`).
+    pub summary: String,
+    /// Linked phrase such as `"issue #1"` / `"pull #2"`.
+    pub ref_label: Option<String>,
+    pub ref_href: Option<String>,
+    pub actor_username: Option<String>,
+    pub actor_avatar: Option<String>,
+    pub repo_owner: Option<String>,
+    pub repo_name: Option<String>,
+    pub created_at: DateTime<Utc>,
+}
+
+pub struct ReactionView {
+    pub emoji: String,
+    pub label: String,
+    pub count: i64,
+    pub mine: bool,
+}
+
+pub struct CommentView {
+    pub id: Uuid,
+    pub author: User,
+    pub avatar_url: String,
+    pub body_html: String,
+    pub created_at: DateTime<Utc>,
+    pub reactions: Vec<ReactionView>,
+}
+
+pub struct DiffFileView {
+    pub path: String,
+    pub anchor: String,
+    pub additions: u32,
+    pub deletions: u32,
+    pub html: String,
+    pub truncated: bool,
+    pub total_lines: usize,
+    pub binary: bool,
+}
+
+pub struct TreeEntryView {
+    pub name: String,
+    pub path: String,
+    pub is_dir: bool,
+    pub mode: String,
+}
+
+pub struct CommitView {
+    pub id: String,
+    pub short_id: String,
+    pub message: String,
+    pub author: String,
+    pub email: String,
+    pub time: i64,
+}
+
+pub struct CollaboratorView {
+    pub user: User,
+    pub role: String,
+    pub avatar_url: String,
+}
+
+pub struct LanguageStatView {
+    pub name: String,
+    pub percent: f64,
+    pub color: String,
+}
+
+pub struct BranchRow {
+    pub name: String,
+    pub is_default: bool,
+    pub updated: String,
+    pub ahead: usize,
+    pub behind: usize,
+    pub pull_number: Option<i32>,
+}
+
+pub struct TagRow {
+    pub name: String,
+    pub short_id: String,
+    pub target: String,
+    pub message: String,
+    pub updated: String,
+    pub has_release: bool,
+}
+
+pub struct ReleaseAssetView {
+    pub id: Uuid,
+    pub filename: String,
+    pub size_label: String,
+    pub content_type: String,
+}
+
+// ── page templates ───────────────────────────────────────────────────────────
+
+#[derive(Template, WebTemplate)]
+#[template(path = "home.html")]
+pub struct HomeTemplate {
+    pub viewer: Option<User>,
+    pub motd: String,
+    pub my_repos: Vec<Repository>,
+    pub activities: Vec<ActivityRow>,
+}
+
+pub struct ExploreRepo {
+    pub owner: String,
+    pub repo: Repository,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "explore.html")]
+pub struct ExploreTemplate {
+    pub viewer: Option<User>,
+    pub repos: Vec<ExploreRepo>,
+    pub query: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "login.html")]
+pub struct LoginTemplate {
+    pub viewer: Option<User>,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "signup.html")]
+pub struct SignupTemplate {
+    pub viewer: Option<User>,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "error.html")]
+pub struct ErrorTemplate {
+    pub viewer: Option<User>,
+    pub status: u16,
+    pub message: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "new_repo.html")]
+pub struct NewRepoTemplate {
+    pub viewer: Option<User>,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "profile_settings.html")]
+pub struct ProfileSettingsTemplate {
+    pub viewer: Option<User>,
+    pub user: User,
+    pub avatar_url: String,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "account_settings.html")]
+pub struct AccountSettingsTemplate {
+    pub viewer: Option<User>,
+    pub user: User,
+    pub emails: Vec<UserEmail>,
+    pub sessions: Vec<SessionView>,
+    pub current_session_id: Option<Uuid>,
+    pub error: Option<String>,
+    pub message: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "mfa_challenge.html")]
+pub struct MfaChallengeTemplate {
+    pub viewer: Option<User>,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "mfa_settings.html")]
+pub struct MfaSettingsTemplate {
+    pub viewer: Option<User>,
+    pub user: User,
+    pub enabled: bool,
+    pub pending: bool,
+    pub secret: Option<String>,
+    pub qr_data_uri: Option<String>,
+    pub recovery_codes: Option<Vec<String>>,
+    pub recovery_remaining: usize,
+    pub error: Option<String>,
+    pub message: Option<String>,
+}
+
+pub struct SessionView {
+    pub id: Uuid,
+    pub created_at: DateTime<Utc>,
+    pub last_seen_at: DateTime<Utc>,
+    pub user_agent: String,
+    pub ip_address: String,
+    pub is_current: bool,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "keys_settings.html")]
+pub struct KeysSettingsTemplate {
+    pub viewer: Option<User>,
+    pub keys: Vec<SshKey>,
+    pub gpg_keys: Vec<GpgKey>,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "profile.html")]
+pub struct ProfileTemplate {
+    pub viewer: Option<User>,
+    pub profile: User,
+    pub avatar_url: String,
+    pub repos: Vec<Repository>,
+    pub starred: Vec<ExploreRepo>,
+    pub watched_activity: Vec<ActivityRow>,
+    pub graph: Vec<CommitDay>,
+    pub has_activity: bool,
+    pub is_self: bool,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_home.html")]
+pub struct RepoHomeTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub owner_avatar: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub branches: Vec<String>,
+    pub current_branch: String,
+    pub entries: Vec<TreeEntryView>,
+    pub readme_html: Option<String>,
+    pub languages: Vec<LanguageStatView>,
+    pub empty: bool,
+    pub latest_commit: Option<CommitView>,
+    pub starred: bool,
+    pub watching: bool,
+    pub forked_from: Option<(String, String)>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_tree.html")]
+pub struct RepoTreeTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub branches: Vec<String>,
+    pub branch: String,
+    pub path: String,
+    pub breadcrumbs: Vec<(String, String)>,
+    pub entries: Vec<TreeEntryView>,
+    pub readme_html: Option<String>,
+    pub latest_commit: Option<CommitView>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_blob.html")]
+pub struct RepoBlobTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub branches: Vec<String>,
+    pub branch: String,
+    pub path: String,
+    pub breadcrumbs: Vec<(String, String)>,
+    pub binary: bool,
+    pub size: usize,
+    pub content_html: Option<String>,
+    pub is_markdown: bool,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_commits.html")]
+pub struct RepoCommitsTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub branches: Vec<String>,
+    pub branch: String,
+    pub commits: Vec<CommitView>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_commit.html")]
+pub struct RepoCommitTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub commit: CommitView,
+    pub message_html: String,
+    pub diff_html: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_diff.html")]
+pub struct RepoDiffTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub commit: CommitView,
+    pub diff_html: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_branches.html")]
+pub struct RepoBranchesTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub branches: Vec<BranchRow>,
+    pub tags: Vec<TagRow>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "issues_list.html")]
+pub struct IssuesListTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub issues: Vec<Issue>,
+    pub state_filter: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "issue_new.html")]
+pub struct IssueNewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub error: Option<String>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "issue_view.html")]
+pub struct IssueViewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub issue: Issue,
+    pub author: User,
+    pub author_avatar: String,
+    pub body_html: String,
+    pub comments: Vec<CommentView>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "pulls_list.html")]
+pub struct PullsListTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub pulls: Vec<PullRequest>,
+    pub state_filter: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "pull_new.html")]
+pub struct PullNewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub branches: Vec<String>,
+    pub error: Option<String>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub upstream: Option<(String, String)>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "pull_view.html")]
+pub struct PullViewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub pull: PullRequest,
+    pub author: User,
+    pub author_avatar: String,
+    pub body_html: String,
+    pub comments: Vec<CommentView>,
+    pub commits: Vec<CommitView>,
+    pub diff_files: Vec<DiffFileView>,
+    pub tab: String,
+    pub conversation_count: usize,
+    pub can_merge: bool,
+    pub merge_styles: Vec<String>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "releases_list.html")]
+pub struct ReleasesListTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub releases: Vec<Release>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "release_new.html")]
+pub struct ReleaseNewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub error: Option<String>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub tag_name: String,
+    pub title: String,
+    pub body: String,
+    pub target: String,
+    pub is_prerelease: bool,
+    pub is_draft: bool,
+    pub branches: Vec<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "release_edit.html")]
+pub struct ReleaseEditTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub release: Release,
+    pub error: Option<String>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub target: String,
+    pub branches: Vec<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "release_view.html")]
+pub struct ReleaseViewTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub release: Release,
+    pub author: User,
+    pub author_avatar: String,
+    pub body_html: String,
+    pub assets: Vec<ReleaseAssetView>,
+    pub tag_short: String,
+    pub tag_target: String,
+    pub clone_http: String,
+    pub clone_ssh: String,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "repo_settings.html")]
+pub struct RepoSettingsTemplate {
+    pub viewer: Option<User>,
+    pub owner: User,
+    pub repo: Repository,
+    pub access: Access,
+    pub collaborators: Vec<CollaboratorView>,
+    pub branches: Vec<String>,
+    pub branch_rules: Vec<BranchRule>,
+    pub clone_http: String,
+    pub clone_ssh: String,
+    pub is_site_admin: bool,
+    pub error: Option<String>,
+}
+
+#[derive(Template, WebTemplate)]
+#[template(path = "admin.html")]
+pub struct AdminTemplate {
+    pub viewer: Option<User>,
+    pub users: Vec<User>,
+    pub motd: String,
+}
diff --git a/static/brand.css b/static/brand.css
new file mode 100644
index 0000000..2bce8b2
--- /dev/null
+++ b/static/brand.css
@@ -0,0 +1,1416 @@
+/* kitgit — brand theme
+   ink on paper. no chrome. */
+
+:root {
+  --kg-bg: #ffffff;
+  --kg-bg-raised: #f7f7f7;
+  --kg-fg: #0a0a0a;
+  --kg-muted: #6b6b6b;
+  --kg-faint: #a3a3a3;
+  --kg-line: #e5e5e5;
+  --kg-line-strong: #0a0a0a;
+  --kg-link: #0a0a0a;
+  --kg-focus: #0a0a0a;
+  --kg-danger: #c1121f;
+  --kg-danger-bg: #fff5f5;
+  --kg-invert-bg: #0a0a0a;
+  --kg-invert-fg: #ffffff;
+  --kg-invert-muted: #a3a3a3;
+  --kg-font: "JetBrainsMono Nerd Font", "JetBrainsMono NF", "JetBrains Mono",
+    "FiraCode Nerd Font", "Fira Code", ui-monospace, "Cascadia Code", "SF Mono",
+    Menlo, Consolas, monospace;
+  --kg-font-size: 14px;
+  --kg-line-height: 1.55;
+  --kg-tracking-tight: -0.02em;
+  --kg-tracking-brand: 0.08em;
+  --kg-space-1: 0.25rem;
+  --kg-space-2: 0.5rem;
+  --kg-space-3: 1rem;
+  --kg-space-4: 1.5rem;
+  --kg-space-5: 2.5rem;
+  --kg-space-6: 4rem;
+  --kg-max: 75rem;
+  --kg-radius: 0;
+  color-scheme: light;
+}
+
+html.kg-dark {
+  --kg-bg: #0a0a0a;
+  --kg-bg-raised: #141414;
+  --kg-fg: #f5f5f5;
+  --kg-muted: #8a8a8a;
+  --kg-faint: #5c5c5c;
+  --kg-line: #262626;
+  --kg-line-strong: #f5f5f5;
+  --kg-link: #f5f5f5;
+  --kg-danger: #ff4d5a;
+  --kg-danger-bg: #2a1214;
+  --kg-invert-bg: #f5f5f5;
+  --kg-invert-fg: #0a0a0a;
+  --kg-invert-muted: #525252;
+  color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  font-size: var(--kg-font-size);
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: var(--kg-font);
+  line-height: var(--kg-line-height);
+  color: var(--kg-fg);
+  background: var(--kg-bg);
+}
+
+a {
+  color: var(--kg-fg);
+}
+
+a:hover {
+  text-decoration-thickness: 2px;
+}
+
+.kg-shell {
+  width: min(100% - 2rem, var(--kg-max));
+  margin-inline: auto;
+}
+
+.kg-mark {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 0.4em;
+  font-weight: 700;
+  font-size: 1.125rem;
+  letter-spacing: var(--kg-tracking-brand);
+  text-decoration: none;
+  color: var(--kg-fg);
+  line-height: 1;
+}
+
+.kg-mark__glyph {
+  font-weight: 700;
+  letter-spacing: 0;
+  font-size: 1.15em;
+  line-height: 1;
+}
+
+.kg-mark__name {
+  letter-spacing: var(--kg-tracking-brand);
+}
+
+.kg-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  padding: var(--kg-space-3) 0;
+  border-bottom: 1px solid var(--kg-line);
+  flex-wrap: wrap;
+}
+
+.kg-bar nav {
+  display: flex;
+  gap: var(--kg-space-3);
+  flex-wrap: wrap;
+}
+
+.kg-bar nav a {
+  color: var(--kg-muted);
+  text-decoration: none;
+  font-size: 0.8125rem;
+}
+
+.kg-bar nav a:hover,
+.kg-bar nav a[aria-current="page"] {
+  color: var(--kg-fg);
+}
+
+.kg-userchip {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5em;
+  text-decoration: none;
+  color: var(--kg-fg);
+  font-size: 0.8125rem;
+}
+
+.kg-avatar,
+.kg-avatar--sm,
+.kg-avatar--lg {
+  display: inline-block;
+  object-fit: cover;
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  border-radius: 0;
+}
+
+.kg-avatar {
+  width: 2.5rem;
+  height: 2.5rem;
+}
+
+.kg-avatar--sm {
+  width: 1.25rem;
+  height: 1.25rem;
+}
+
+.kg-avatar--lg {
+  width: 4.5rem;
+  height: 4.5rem;
+}
+
+.kg-footer {
+  padding: var(--kg-space-4) 0 var(--kg-space-6);
+  color: var(--kg-faint);
+  font-size: 0.75rem;
+  letter-spacing: 0.06em;
+}
+
+.kg-display {
+  font-weight: 700;
+  font-size: clamp(1.5rem, 3vw, 2rem);
+  line-height: 1.2;
+  letter-spacing: var(--kg-tracking-tight);
+  margin: 0;
+}
+
+.kg-title {
+  font-weight: 700;
+  font-size: 1.125rem;
+  margin: 0;
+}
+
+.kg-muted {
+  color: var(--kg-muted);
+}
+
+.kg-faint {
+  color: var(--kg-faint);
+}
+
+.kg-kicker {
+  display: block;
+  font-size: 0.75rem;
+  letter-spacing: 0.14em;
+  text-transform: uppercase;
+  color: var(--kg-muted);
+  margin: 0 0 var(--kg-space-3);
+}
+
+.kg-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.5em;
+  padding: 0.55em 1em;
+  font-family: var(--kg-font);
+  font-size: 0.875rem;
+  border: 1px solid var(--kg-line-strong);
+  background: var(--kg-invert-bg);
+  color: var(--kg-invert-fg);
+  text-decoration: none;
+  cursor: pointer;
+}
+
+.kg-btn:hover {
+  opacity: 0.85;
+}
+
+.kg-btn--ghost {
+  background: transparent;
+  color: var(--kg-fg);
+}
+
+.kg-btn--ghost:hover {
+  opacity: 1;
+  background: var(--kg-bg-raised);
+}
+
+.kg-btn--danger {
+  background: var(--kg-danger);
+  color: #fff;
+  border-color: var(--kg-danger);
+}
+
+.kg-btn--danger:hover {
+  opacity: 0.9;
+}
+
+.kg-btn--danger-ghost {
+  background: transparent;
+  color: var(--kg-danger);
+  border-color: var(--kg-danger);
+}
+
+.kg-btn--danger-ghost:hover {
+  background: var(--kg-danger-bg);
+}
+
+.kg-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+}
+
+.kg-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  border: 1px solid var(--kg-line);
+}
+
+.kg-list li {
+  display: flex;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  padding: var(--kg-space-2) var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  font-size: 0.875rem;
+}
+
+.kg-list li:last-child {
+  border-bottom: 0;
+}
+
+.kg-list a {
+  text-decoration: none;
+}
+
+.kg-list a:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.2em;
+}
+
+.kg-section {
+  padding: var(--kg-space-4) 0;
+}
+
+.kg-flash {
+  padding: var(--kg-space-2) var(--kg-space-3);
+  border: 1px solid var(--kg-line-strong);
+  margin: var(--kg-space-3) 0;
+  font-size: 0.875rem;
+}
+
+.kg-form {
+  display: flex;
+  flex-direction: column;
+  gap: var(--kg-space-3);
+  max-width: 28rem;
+}
+
+.kg-form label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  font-size: 0.8125rem;
+  color: var(--kg-muted);
+}
+
+.kg-form input,
+.kg-form textarea,
+.kg-form select {
+  font-family: var(--kg-font);
+  font-size: 0.875rem;
+  padding: 0.5em 0.65em;
+  border: 1px solid var(--kg-line-strong);
+  background: var(--kg-bg);
+  color: var(--kg-fg);
+  border-radius: 0;
+}
+
+.kg-form textarea {
+  min-height: 6rem;
+  resize: vertical;
+}
+
+.kg-form .row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  color: var(--kg-fg);
+}
+
+.kg-tabs {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  margin: var(--kg-space-3) 0;
+  flex-wrap: wrap;
+}
+
+.kg-tabs__links {
+  display: flex;
+  gap: var(--kg-space-3);
+  flex-wrap: wrap;
+  align-items: flex-end;
+}
+
+.kg-tabs a {
+  text-decoration: none;
+  color: var(--kg-muted);
+  font-size: 0.8125rem;
+  padding-bottom: 0.5rem;
+  border-bottom: 2px solid transparent;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35em;
+}
+
+.kg-tabs a[aria-current="page"] {
+  color: var(--kg-fg);
+  border-bottom-color: var(--kg-fg);
+}
+
+.kg-icon {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  vertical-align: -0.125em;
+  background-color: currentColor;
+  -webkit-mask: var(--icon) center / contain no-repeat;
+  mask: var(--icon) center / contain no-repeat;
+  flex-shrink: 0;
+}
+
+.kg-motd {
+  color: var(--kg-muted);
+  white-space: pre-wrap;
+  margin: var(--kg-space-2) 0 0;
+}
+
+.kg-activity {
+  display: inline-flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.35em 0.5em;
+}
+
+.kg-dropdown {
+  position: relative;
+  margin-bottom: 0.35rem;
+}
+
+.kg-dropdown > summary {
+  list-style: none;
+  cursor: pointer;
+}
+
+.kg-dropdown > summary::-webkit-details-marker {
+  display: none;
+}
+
+.kg-dropdown__panel {
+  position: absolute;
+  right: 0;
+  top: calc(100% + 0.35rem);
+  z-index: 20;
+  min-width: 18rem;
+  padding: var(--kg-space-3);
+  background: var(--kg-bg);
+  border: 1px solid var(--kg-line-strong);
+  display: flex;
+  flex-direction: column;
+  gap: var(--kg-space-2);
+}
+
+.kg-dropdown__item {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5em;
+  text-decoration: none;
+  font-size: 0.8125rem;
+  color: var(--kg-fg);
+  padding: 0.35rem 0;
+}
+
+.kg-clone-row {
+  display: grid;
+  grid-template-columns: 2.5rem 1fr auto;
+  gap: 0.35rem;
+  align-items: center;
+  font-size: 0.75rem;
+  color: var(--kg-muted);
+}
+
+.kg-clone-row input {
+  font-family: var(--kg-font);
+  font-size: 0.75rem;
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  color: var(--kg-fg);
+  padding: 0.4em 0.5em;
+  min-width: 0;
+}
+
+.kg-branchbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  flex-wrap: wrap;
+  border: 1px solid var(--kg-line);
+  padding: 0.55rem 0.75rem;
+  margin: var(--kg-space-3) 0;
+  background: var(--kg-bg-raised);
+}
+
+.kg-branchbar__left {
+  display: flex;
+  align-items: center;
+  gap: var(--kg-space-3);
+  flex-wrap: wrap;
+  min-width: 0;
+  flex: 1 1 auto;
+}
+
+.kg-branchbar__actions {
+  display: flex;
+  align-items: center;
+  gap: var(--kg-space-2);
+  flex-wrap: wrap;
+}
+
+.kg-branchbar__branch {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4em;
+  text-decoration: none;
+  font-weight: 700;
+  color: var(--kg-fg);
+}
+
+.kg-branchbar__commit {
+  display: flex;
+  align-items: center;
+  gap: var(--kg-space-2);
+  min-width: 0;
+  flex: 1;
+  justify-content: flex-end;
+}
+
+.kg-branchbar__sha {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35em;
+  text-decoration: none;
+  color: var(--kg-muted);
+  font-size: 0.8125rem;
+  flex-shrink: 0;
+}
+
+.kg-branchbar__msg {
+  color: var(--kg-fg);
+  text-decoration: none;
+  font-size: 0.8125rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-width: 28rem;
+}
+
+.kg-branchbar__msg:hover,
+.kg-branchbar__sha:hover,
+.kg-branchbar__branch:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
+.kg-dropdown__panel--upload {
+  min-width: 20rem;
+}
+
+.kg-upload {
+  display: flex;
+  flex-direction: column;
+  gap: var(--kg-space-3);
+  margin: 0;
+  padding: 0;
+  border: 0;
+}
+
+.kg-upload__file,
+.kg-upload__message {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  font-size: 0.8125rem;
+  color: var(--kg-muted);
+}
+
+.kg-upload input[type="file"] {
+  width: 100%;
+  max-width: 100%;
+  font-size: 0.75rem;
+}
+
+.kg-upload input[type="text"] {
+  font-family: var(--kg-font);
+  font-size: 0.8125rem;
+  border: 1px solid var(--kg-line);
+  padding: 0.4em 0.55em;
+  background: var(--kg-bg-raised);
+  color: var(--kg-fg);
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.kg-upload .kg-btn {
+  align-self: flex-start;
+}
+
+.kg-blobbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-2);
+  padding: var(--kg-space-2) 0;
+  margin: var(--kg-space-2) 0 var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+}
+
+.kg-blobbar__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+  align-items: center;
+}
+
+.kg-danger-zone {
+  margin-top: var(--kg-space-5);
+  padding: var(--kg-space-3);
+  border: 1px solid var(--kg-danger);
+  background: var(--kg-danger-bg);
+}
+
+.kg-danger-zone .kg-kicker {
+  color: var(--kg-danger);
+}
+
+.kg-danger-zone .kg-form {
+  max-width: none;
+  gap: var(--kg-space-2);
+  margin: var(--kg-space-2) 0 0;
+}
+
+.kg-danger-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: var(--kg-space-2);
+}
+
+.kg-danger-row label {
+  flex: 1 1 12rem;
+  margin: 0;
+  gap: 0.25rem;
+}
+
+.kg-settings-section {
+  margin-top: var(--kg-space-5);
+  padding-top: var(--kg-space-3);
+  border-top: 1px solid var(--kg-line);
+}
+
+.kg-settings-section > .kg-kicker {
+  margin-bottom: var(--kg-space-2);
+}
+
+.kg-social {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+  margin: 0;
+  margin-left: auto;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.kg-social form {
+  margin: 0;
+}
+
+.kg-reactions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem;
+  margin-top: var(--kg-space-2);
+}
+
+.kg-reaction {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25em;
+  font-size: 0.75rem;
+  padding: 0.15em 0.45em;
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg);
+  color: var(--kg-fg);
+  cursor: pointer;
+  font-family: var(--kg-font);
+}
+
+.kg-reaction[aria-pressed="true"] {
+  border-color: var(--kg-fg);
+  background: var(--kg-bg-raised);
+}
+
+.kg-branch-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 0.875rem;
+  margin: var(--kg-space-3) 0;
+}
+
+.kg-branch-table th,
+.kg-branch-table td {
+  text-align: left;
+  padding: var(--kg-space-2) var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  vertical-align: middle;
+}
+
+.kg-branch-table th {
+  color: var(--kg-muted);
+  font-weight: 500;
+  font-size: 0.75rem;
+}
+
+.kg-branch-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem;
+}
+
+.kg-subtitle {
+  font-family: var(--kg-font);
+  font-size: 1.1rem;
+  letter-spacing: var(--kg-tracking-tight);
+  margin: var(--kg-space-4) 0 var(--kg-space-2);
+}
+
+.kg-check {
+  display: flex !important;
+  flex-direction: row !important;
+  align-items: center;
+  gap: 0.5rem;
+  font-size: 0.875rem;
+}
+
+.kg-check input {
+  width: auto;
+}
+
+.kg-tag-create {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+  gap: var(--kg-space-3);
+  align-items: end;
+  margin: var(--kg-space-3) 0 var(--kg-space-4);
+  padding: var(--kg-space-3);
+  border: 1px solid var(--kg-line);
+}
+
+.kg-tag-create .kg-actions {
+  margin: 0;
+}
+
+.kg-release-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: var(--kg-space-4);
+  flex-wrap: wrap;
+  margin-bottom: var(--kg-space-2);
+}
+
+.kg-release-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem);
+  gap: var(--kg-space-5);
+  margin-top: var(--kg-space-4);
+  align-items: start;
+}
+
+@media (max-width: 800px) {
+  .kg-release-layout {
+    grid-template-columns: 1fr;
+  }
+}
+
+.kg-release-notes {
+  margin-top: var(--kg-space-2);
+}
+
+.kg-release-aside {
+  border: 1px solid var(--kg-line);
+  padding: var(--kg-space-3);
+}
+
+.kg-asset-list {
+  list-style: none;
+  padding: 0;
+  margin: var(--kg-space-2) 0;
+}
+
+.kg-asset-list li {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.4rem 0;
+  border-bottom: 1px solid var(--kg-line);
+  font-size: 0.875rem;
+}
+
+.kg-asset-list li:last-child {
+  border-bottom: none;
+}
+
+.kg-asset-list a {
+  flex: 1;
+  min-width: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.kg-asset-source {
+  margin: var(--kg-space-3) 0;
+}
+
+.kg-asset-upload {
+  margin-top: var(--kg-space-3);
+  padding-top: var(--kg-space-3);
+  border-top: 1px solid var(--kg-line);
+}
+
+.kg-release-list {
+  list-style: none;
+  padding: 0;
+  margin: var(--kg-space-4) 0 0;
+}
+
+.kg-release-list__item {
+  padding: var(--kg-space-3) 0;
+  border-bottom: 1px solid var(--kg-line);
+}
+
+.kg-release-list__title {
+  font-size: 1.05rem;
+  font-weight: 600;
+}
+
+.kg-badge--pre {
+  color: var(--kg-muted);
+  border-style: dashed;
+}
+
+.kg-contrib-empty {
+  color: var(--kg-muted);
+  font-size: 0.875rem;
+}
+
+.kg-diff {
+  border: 1px solid var(--kg-line);
+  overflow-x: auto;
+  font-size: 0.8125rem;
+  margin: 0;
+  padding: 0;
+  line-height: 1.45;
+  background: var(--kg-bg);
+}
+
+.kg-diff .kg-diff__add {
+  display: block;
+  background: color-mix(in srgb, #1a7f37 14%, var(--kg-bg));
+  color: color-mix(in srgb, #1a7f37 70%, var(--kg-fg));
+}
+
+.kg-diff .kg-diff__del {
+  display: block;
+  background: color-mix(in srgb, #cf222e 12%, var(--kg-bg));
+  color: color-mix(in srgb, #cf222e 65%, var(--kg-fg));
+}
+
+.kg-diff .kg-diff__hunk {
+  display: block;
+  background: var(--kg-bg-raised);
+  color: var(--kg-muted);
+}
+
+.kg-diff .kg-diff__meta {
+  display: block;
+  color: var(--kg-muted);
+}
+
+.kg-diff__truncated {
+  margin: 0;
+  padding: var(--kg-space-3);
+  border-top: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  color: var(--kg-muted);
+  font-size: 0.8125rem;
+}
+
+.kg-pr-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+  border-bottom: 1px solid var(--kg-line);
+  margin: var(--kg-space-4) 0 var(--kg-space-4);
+  padding-bottom: 0;
+}
+
+.kg-pr-tabs a {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+  padding: 0.55rem 0.15rem;
+  margin-bottom: -1px;
+  border-bottom: 2px solid transparent;
+  color: var(--kg-muted);
+  text-decoration: none;
+  font-size: 0.875rem;
+}
+
+.kg-pr-tabs a:hover {
+  color: var(--kg-fg);
+}
+
+.kg-pr-tabs a[aria-current="page"] {
+  color: var(--kg-fg);
+  border-bottom-color: var(--kg-fg);
+}
+
+.kg-pr-tabs__count {
+  display: inline-block;
+  min-width: 1.25rem;
+  padding: 0 0.35rem;
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  font-size: 0.75rem;
+  text-align: center;
+}
+
+.kg-pr-files {
+  display: grid;
+  grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
+  gap: var(--kg-space-4);
+  align-items: start;
+}
+
+.kg-pr-files__nav {
+  position: sticky;
+  top: var(--kg-space-3);
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg);
+  max-height: calc(100vh - 4rem);
+  overflow: auto;
+}
+
+.kg-pr-files__nav-title {
+  margin: 0;
+  padding: var(--kg-space-2) var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  font-size: 0.75rem;
+  letter-spacing: 0.06em;
+  text-transform: lowercase;
+  color: var(--kg-muted);
+}
+
+.kg-pr-files__nav ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.kg-pr-files__nav a {
+  display: flex;
+  justify-content: space-between;
+  gap: var(--kg-space-2);
+  padding: 0.55rem var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  color: var(--kg-fg);
+  text-decoration: none;
+  font-size: 0.8125rem;
+}
+
+.kg-pr-files__nav a:hover {
+  background: var(--kg-bg-raised);
+}
+
+.kg-pr-files__path {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  min-width: 0;
+}
+
+.kg-pr-files__stat {
+  flex-shrink: 0;
+  font-variant-numeric: tabular-nums;
+  font-size: 0.75rem;
+}
+
+.kg-pr-files__add {
+  color: #1a7f37;
+}
+
+.kg-pr-files__del {
+  color: #cf222e;
+}
+
+.kg-pr-files__panels {
+  display: flex;
+  flex-direction: column;
+  gap: var(--kg-space-4);
+  min-width: 0;
+}
+
+.kg-diff-file {
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg);
+}
+
+.kg-diff-file__head {
+  display: flex;
+  justify-content: space-between;
+  gap: var(--kg-space-2);
+  align-items: baseline;
+  padding: var(--kg-space-2) var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  font-size: 0.8125rem;
+}
+
+.kg-diff-file__head strong {
+  font-weight: 500;
+  word-break: break-all;
+}
+
+@media (max-width: 800px) {
+  .kg-pr-files {
+    grid-template-columns: 1fr;
+  }
+
+  .kg-pr-files__nav {
+    position: static;
+    max-height: 12rem;
+  }
+}
+
+.kg-landing {
+  padding: var(--kg-space-6) 0 var(--kg-space-5);
+  min-height: 60vh;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: var(--kg-space-3);
+}
+
+.kg-landing__brand {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 0.45em;
+  margin: 0;
+  font-weight: 700;
+  font-size: clamp(2rem, 5vw, 3rem);
+  letter-spacing: var(--kg-tracking-brand);
+  line-height: 1;
+}
+
+.kg-landing__brand .kg-mark__glyph {
+  font-size: 1.05em;
+}
+
+.kg-explore-search {
+  display: flex;
+  gap: var(--kg-space-2);
+  flex-wrap: wrap;
+  margin-top: var(--kg-space-3);
+  max-width: 36rem;
+}
+
+.kg-explore-search input {
+  flex: 1;
+  min-width: 12rem;
+  font-family: var(--kg-font);
+  font-size: 0.875rem;
+  border: 1px solid var(--kg-line-strong);
+  padding: 0.55em 0.75em;
+  background: var(--kg-bg);
+  color: var(--kg-fg);
+}
+
+.kg-sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
+.kg-auth {
+  max-width: 22rem;
+}
+
+.kg-auth .kg-landing__brand {
+  font-size: 1.5rem;
+  margin-bottom: var(--kg-space-3);
+}
+
+.kg-bar nav a {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35em;
+}
+
+.kg-meta {
+  color: var(--kg-muted);
+  font-size: 0.75rem;
+}
+
+.kg-langbar {
+  display: flex;
+  height: 0.5rem;
+  width: 100%;
+  border: 1px solid var(--kg-line);
+  margin: var(--kg-space-2) 0;
+  overflow: hidden;
+}
+
+.kg-langbar span {
+  display: block;
+  height: 100%;
+  min-width: 0;
+}
+
+.kg-langlist {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2) var(--kg-space-3);
+  font-size: 0.75rem;
+  color: var(--kg-muted);
+  list-style: none;
+  padding: 0;
+  margin: 0 0 var(--kg-space-3);
+}
+
+.kg-langlist li {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+  color: var(--kg-fg);
+}
+
+.kg-langdot {
+  width: 0.55rem;
+  height: 0.55rem;
+  border-radius: 50%;
+  flex-shrink: 0;
+  border: 1px solid color-mix(in srgb, var(--kg-fg) 12%, transparent);
+}
+
+.kg-tree {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  border: 1px solid var(--kg-line);
+}
+
+.kg-tree li {
+  display: grid;
+  grid-template-columns: 1.5rem 1fr auto auto;
+  align-items: center;
+  gap: var(--kg-space-2);
+  padding: 0.35rem var(--kg-space-3);
+  border-bottom: 1px solid var(--kg-line);
+  font-size: 0.875rem;
+}
+
+.kg-tree li:last-child {
+  border-bottom: 0;
+}
+
+.kg-tree a {
+  text-decoration: none;
+}
+
+.kg-tree__dl {
+  padding: 0.15rem 0.35rem;
+  opacity: 0.55;
+}
+
+.kg-tree li:hover .kg-tree__dl,
+.kg-tree__dl:focus-visible {
+  opacity: 1;
+}
+
+.kg-codeview,
+.kg-blob {
+  display: flex;
+  align-items: flex-start;
+  border: 1px solid var(--kg-line);
+  overflow-x: auto;
+  font-family: var(--kg-font);
+  font-size: 0.8125rem;
+  line-height: 1.5;
+  margin-top: var(--kg-space-3);
+  tab-size: 4;
+}
+
+.kg-blob__nums {
+  flex: 0 0 auto;
+  margin: 0;
+  padding: 0.75rem 0.75rem 0.75rem 1rem;
+  min-width: 3rem;
+  color: var(--kg-faint);
+  text-align: right;
+  user-select: none;
+  white-space: pre;
+  border-right: 1px solid var(--kg-line);
+  font-family: inherit;
+  font-size: inherit;
+  font-weight: 400;
+  font-variant-numeric: tabular-nums;
+  line-height: inherit;
+  background: var(--kg-bg);
+}
+
+.kg-blob__code {
+  flex: 1 1 auto;
+  margin: 0;
+  padding: 0.75rem 1rem 0.75rem 0.75rem;
+  min-width: 0;
+  overflow-x: auto;
+  white-space: pre;
+  font-family: inherit;
+  font-size: inherit;
+  font-weight: 400;
+  line-height: inherit;
+  background: transparent;
+  border: 0;
+  color: inherit;
+}
+
+.kg-blob__code .kg-highlight,
+.kg-blob__code code {
+  display: block;
+  margin: 0;
+  padding: 0;
+  font-family: inherit;
+  font-size: inherit;
+  font-weight: inherit;
+  line-height: inherit;
+  white-space: inherit;
+  background: transparent;
+  color: inherit;
+}
+
+/* syntect ClassStyle::Spaced token colors */
+.kg-codeview .comment,
+.kg-codeview .punctuation.definition.comment { color: #6b6b6b; font-style: italic; }
+.kg-codeview .string,
+.kg-codeview .string.quoted,
+.kg-codeview .constant.character.escape { color: #0b6e4f; }
+.kg-codeview .constant.numeric,
+.kg-codeview .constant.language,
+.kg-codeview .constant.character { color: #8a4b08; }
+.kg-codeview .keyword,
+.kg-codeview .keyword.control,
+.kg-codeview .keyword.operator,
+.kg-codeview .storage,
+.kg-codeview .storage.type,
+.kg-codeview .storage.modifier { color: #0a0a0a; font-weight: 700; }
+.kg-codeview .entity.name.function,
+.kg-codeview .support.function { color: #1a4a8a; }
+.kg-codeview .entity.name.class,
+.kg-codeview .entity.name.struct,
+.kg-codeview .entity.name.type,
+.kg-codeview .entity.name.namespace,
+.kg-codeview .support.type,
+.kg-codeview .support.class { color: #5b2d8e; }
+.kg-codeview .variable,
+.kg-codeview .variable.parameter,
+.kg-codeview .variable.other { color: #0a0a0a; }
+.kg-codeview .meta.annotation,
+.kg-codeview .entity.name.tag { color: #8a1c1c; }
+.kg-codeview .entity.other.attribute-name { color: #8a4b08; }
+.kg-codeview .punctuation { color: #3d3d3d; }
+
+html.kg-dark .kg-codeview .comment,
+html.kg-dark .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
+html.kg-dark .kg-codeview .string,
+html.kg-dark .kg-codeview .string.quoted,
+html.kg-dark .kg-codeview .constant.character.escape { color: #5ec49a; }
+html.kg-dark .kg-codeview .constant.numeric,
+html.kg-dark .kg-codeview .constant.language,
+html.kg-dark .kg-codeview .constant.character { color: #e0a35a; }
+html.kg-dark .kg-codeview .keyword,
+html.kg-dark .kg-codeview .keyword.control,
+html.kg-dark .kg-codeview .keyword.operator,
+html.kg-dark .kg-codeview .storage,
+html.kg-dark .kg-codeview .storage.type,
+html.kg-dark .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
+html.kg-dark .kg-codeview .entity.name.function,
+html.kg-dark .kg-codeview .support.function { color: #7eb0f0; }
+html.kg-dark .kg-codeview .entity.name.class,
+html.kg-dark .kg-codeview .entity.name.struct,
+html.kg-dark .kg-codeview .entity.name.type,
+html.kg-dark .kg-codeview .entity.name.namespace,
+html.kg-dark .kg-codeview .support.type,
+html.kg-dark .kg-codeview .support.class { color: #c9a0ff; }
+html.kg-dark .kg-codeview .variable,
+html.kg-dark .kg-codeview .variable.parameter,
+html.kg-dark .kg-codeview .variable.other { color: #f5f5f5; }
+html.kg-dark .kg-codeview .meta.annotation,
+html.kg-dark .kg-codeview .entity.name.tag { color: #ff8a8a; }
+html.kg-dark .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
+html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; }
+
+.kg-readme {
+  border: 1px solid var(--kg-line);
+  padding: var(--kg-space-4);
+  margin-top: var(--kg-space-4);
+}
+
+.kg-readme h1,
+.kg-readme h2,
+.kg-readme h3 {
+  font-family: var(--kg-font);
+  letter-spacing: var(--kg-tracking-tight);
+}
+
+.kg-readme pre {
+  background: var(--kg-bg-raised);
+  padding: var(--kg-space-3);
+  overflow-x: auto;
+  border: 1px solid var(--kg-line);
+}
+
+.kg-readme code {
+  font-family: var(--kg-font);
+  font-size: 0.875em;
+}
+
+.kg-readme table {
+  border-collapse: collapse;
+  width: 100%;
+  font-size: 0.875rem;
+}
+
+.kg-readme th,
+.kg-readme td {
+  border: 1px solid var(--kg-line);
+  padding: 0.35rem 0.5rem;
+  text-align: left;
+}
+
+.kg-graph {
+  display: grid;
+  grid-template-columns: repeat(53, 1fr);
+  gap: 2px;
+  margin: var(--kg-space-3) 0;
+}
+
+.kg-graph i {
+  aspect-ratio: 1;
+  background: var(--kg-line);
+  display: block;
+}
+
+.kg-graph i[data-n="1"] {
+  background: color-mix(in srgb, var(--kg-fg) 25%, var(--kg-bg));
+}
+.kg-graph i[data-n="2"] {
+  background: color-mix(in srgb, var(--kg-fg) 45%, var(--kg-bg));
+}
+.kg-graph i[data-n="3"] {
+  background: color-mix(in srgb, var(--kg-fg) 70%, var(--kg-bg));
+}
+.kg-graph i[data-n="4"] {
+  background: var(--kg-fg);
+}
+
+.kg-profile-head {
+  display: flex;
+  gap: var(--kg-space-4);
+  align-items: flex-start;
+  margin-bottom: var(--kg-space-4);
+  flex-wrap: wrap;
+}
+
+.kg-profile-head__main {
+  flex: 1 1 12rem;
+  min-width: 0;
+}
+
+.kg-comment {
+  border: 1px solid var(--kg-line);
+  padding: var(--kg-space-3);
+  margin: var(--kg-space-2) 0;
+}
+
+.kg-comment header {
+  display: flex;
+  justify-content: space-between;
+  gap: var(--kg-space-2);
+  margin-bottom: var(--kg-space-2);
+  font-size: 0.75rem;
+  color: var(--kg-muted);
+}
+
+.kg-badge {
+  display: inline-block;
+  border: 1px solid var(--kg-line-strong);
+  padding: 0.1em 0.4em;
+  font-size: 0.7rem;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+}
+
+.kg-badge--open {
+  background: var(--kg-fg);
+  color: var(--kg-bg);
+}
+
+.kg-badge--closed {
+  background: transparent;
+  color: var(--kg-muted);
+}
+
+.kg-clone {
+  display: block;
+  padding: var(--kg-space-2) var(--kg-space-3);
+  background: var(--kg-invert-bg);
+  color: var(--kg-invert-fg);
+  font-size: 0.75rem;
+  overflow-x: auto;
+  margin: var(--kg-space-3) 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  * {
+    animation: none !important;
+  }
+}
diff --git a/static/icons/chevron-down.svg b/static/icons/chevron-down.svg
new file mode 100644
index 0000000..a81a4b0
--- /dev/null
+++ b/static/icons/chevron-down.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
diff --git a/static/icons/circle.svg b/static/icons/circle.svg
new file mode 100644
index 0000000..d199c5c
--- /dev/null
+++ b/static/icons/circle.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>
diff --git a/static/icons/code.svg b/static/icons/code.svg
new file mode 100644
index 0000000..d3d98f0
--- /dev/null
+++ b/static/icons/code.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
diff --git a/static/icons/copy.svg b/static/icons/copy.svg
new file mode 100644
index 0000000..4fb0db9
--- /dev/null
+++ b/static/icons/copy.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
diff --git a/static/icons/download.svg b/static/icons/download.svg
new file mode 100644
index 0000000..b466641
--- /dev/null
+++ b/static/icons/download.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
diff --git a/static/icons/eye.svg b/static/icons/eye.svg
new file mode 100644
index 0000000..8f1ff9a
--- /dev/null
+++ b/static/icons/eye.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
diff --git a/static/icons/file.svg b/static/icons/file.svg
new file mode 100644
index 0000000..a878855
--- /dev/null
+++ b/static/icons/file.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
diff --git a/static/icons/folder.svg b/static/icons/folder.svg
new file mode 100644
index 0000000..47fe1aa
--- /dev/null
+++ b/static/icons/folder.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
diff --git a/static/icons/git-branch.svg b/static/icons/git-branch.svg
new file mode 100644
index 0000000..a184356
--- /dev/null
+++ b/static/icons/git-branch.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
diff --git a/static/icons/git-commit.svg b/static/icons/git-commit.svg
new file mode 100644
index 0000000..5e20767
--- /dev/null
+++ b/static/icons/git-commit.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/></svg>
diff --git a/static/icons/git-fork.svg b/static/icons/git-fork.svg
new file mode 100644
index 0000000..5510137
--- /dev/null
+++ b/static/icons/git-fork.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/></svg>
diff --git a/static/icons/git-pull-request.svg b/static/icons/git-pull-request.svg
new file mode 100644
index 0000000..b8c8bff
--- /dev/null
+++ b/static/icons/git-pull-request.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/></svg>
diff --git a/static/icons/heart.svg b/static/icons/heart.svg
new file mode 100644
index 0000000..5d53fbe
--- /dev/null
+++ b/static/icons/heart.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>
diff --git a/static/icons/history.svg b/static/icons/history.svg
new file mode 100644
index 0000000..937d293
--- /dev/null
+++ b/static/icons/history.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
diff --git a/static/icons/log-in.svg b/static/icons/log-in.svg
new file mode 100644
index 0000000..5d1eb95
--- /dev/null
+++ b/static/icons/log-in.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
diff --git a/static/icons/log-out.svg b/static/icons/log-out.svg
new file mode 100644
index 0000000..8cdb5e8
--- /dev/null
+++ b/static/icons/log-out.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
diff --git a/static/icons/package.svg b/static/icons/package.svg
new file mode 100644
index 0000000..7b7e970
--- /dev/null
+++ b/static/icons/package.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
diff --git a/static/icons/pencil.svg b/static/icons/pencil.svg
new file mode 100644
index 0000000..55a49fc
--- /dev/null
+++ b/static/icons/pencil.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>
diff --git a/static/icons/plus.svg b/static/icons/plus.svg
new file mode 100644
index 0000000..e4c6006
--- /dev/null
+++ b/static/icons/plus.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
diff --git a/static/icons/settings.svg b/static/icons/settings.svg
new file mode 100644
index 0000000..22e3ea9
--- /dev/null
+++ b/static/icons/settings.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
diff --git a/static/icons/shield.svg b/static/icons/shield.svg
new file mode 100644
index 0000000..280ba34
--- /dev/null
+++ b/static/icons/shield.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>
diff --git a/static/icons/star.svg b/static/icons/star.svg
new file mode 100644
index 0000000..56b8ae2
--- /dev/null
+++ b/static/icons/star.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
diff --git a/static/icons/trash.svg b/static/icons/trash.svg
new file mode 100644
index 0000000..f0edd59
--- /dev/null
+++ b/static/icons/trash.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
diff --git a/static/icons/upload.svg b/static/icons/upload.svg
new file mode 100644
index 0000000..6d4b2c1
--- /dev/null
+++ b/static/icons/upload.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>
diff --git a/static/icons/user.svg b/static/icons/user.svg
new file mode 100644
index 0000000..ba4ee23
--- /dev/null
+++ b/static/icons/user.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
diff --git a/templates/account_settings.html b/templates/account_settings.html
new file mode 100644
index 0000000..248b87f
--- /dev/null
+++ b/templates/account_settings.html
@@ -0,0 +1,153 @@
+{% extends "layout.html" %}
+
+{% block title %}account settings — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">settings</span>
+  <h1 class="kg-title">Account</h1>
+  <nav class="kg-tabs" aria-label="Settings">
+    <a href="/settings/profile">profile</a>
+    <a href="/settings/account" aria-current="page">account</a>
+    <a href="/settings/mfa">mfa</a>
+    <a href="/settings/keys">ssh / gpg</a>
+  </nav>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  {% if let Some(msg) = message %}
+    <div class="kg-flash">{{ msg }}</div>
+  {% endif %}
+
+  <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0">
+    <span class="kg-kicker">username</span>
+    <form class="kg-form" method="post" action="/settings/account/username">
+      <label>
+        username
+        <input type="text" name="username" value="{{ user.username }}" required pattern="[a-z0-9][a-z0-9_-]{0,38}" />
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">change username</button>
+      </div>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">password</span>
+    <p class="kg-muted">Change your account password.</p>
+    <form class="kg-form" method="post" action="/settings/account/password">
+      <label>
+        current password
+        <input type="password" name="current_password" required autocomplete="current-password" />
+      </label>
+      <label>
+        new password
+        <input type="password" name="new_password" required minlength="8" autocomplete="new-password" />
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">change password</button>
+      </div>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">emails</span>
+    <p class="kg-muted">Emails are hidden on your profile unless you opt in. Extra addresses help match commit authors.</p>
+    <form class="kg-form" method="post" action="/settings/account/privacy">
+      <label class="row">
+        <input type="checkbox" name="show_email"{% if user.show_email %} checked{% endif %} />
+        show primary email on profile
+      </label>
+      <label class="row">
+        <input type="checkbox" name="vigilant_mode"{% if user.vigilant_mode %} checked{% endif %} />
+        vigilant mode (flag unsigned commits)
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">save privacy</button>
+      </div>
+    </form>
+    {% if !emails.is_empty() %}
+    <ul class="kg-list" style="margin-top:1rem">
+      {% for e in emails %}
+      <li>
+        <span>
+          {{ e.email }}
+          {% if e.is_primary %}<span class="kg-badge">primary</span>{% endif %}
+          {% if e.verified %}<span class="kg-badge">verified</span>{% endif %}
+        </span>
+        {% if !e.is_primary %}
+        <form method="post" action="/settings/account/emails/{{ e.id }}/delete">
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button>
+        </form>
+        {% endif %}
+      </li>
+      {% endfor %}
+    </ul>
+    {% endif %}
+    <form class="kg-form" method="post" action="/settings/account/emails" style="margin-top:1rem">
+      <label>
+        add email
+        <input type="email" name="email" required />
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">add</button>
+      </div>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">two-factor authentication</span>
+    <p class="kg-muted">Add an authenticator app for an extra sign-in step.</p>
+    <div class="kg-actions">
+      <a class="kg-btn kg-btn--ghost" href="/settings/mfa">manage MFA</a>
+    </div>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">sessions</span>
+    {% if sessions.is_empty() %}
+      <p class="kg-muted">No active sessions.</p>
+    {% else %}
+      <ul class="kg-list">
+        {% for s in sessions %}
+        <li>
+          <span>
+            {% if s.is_current %}<span class="kg-badge">current</span>{% endif %}
+            <span class="kg-meta">{{ s.ip_address }}{% if !s.user_agent.is_empty() %} · {{ s.user_agent }}{% endif %}</span>
+            <span class="kg-meta">last seen {{ s.last_seen_at }}</span>
+          </span>
+          {% if !s.is_current %}
+          <form method="post" action="/settings/account/sessions/{{ s.id }}/revoke">
+            <button class="kg-btn kg-btn--danger-ghost" type="submit">revoke</button>
+          </form>
+          {% endif %}
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+    <form method="post" action="/settings/account/sessions/revoke-others" style="margin-top:1rem">
+      <button class="kg-btn kg-btn--ghost" type="submit">revoke other sessions</button>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">GDPR</span>
+    <div class="kg-actions">
+      <a class="kg-btn kg-btn--ghost" href="/settings/account/export">export my data</a>
+    </div>
+  </div>
+
+  <div class="kg-danger-zone">
+    <span class="kg-kicker">delete account</span>
+    <form class="kg-form" method="post" action="/settings/account/delete">
+      <div class="kg-danger-row">
+        <label>
+          type <code>{{ user.username }}</code> to delete
+          <input type="text" name="confirm" required autocomplete="off" />
+        </label>
+        <button class="kg-btn kg-btn--danger" type="submit">delete account</button>
+      </div>
+    </form>
+  </div>
+</section>
+{% endblock %}
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 0000000..936a9f5
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,44 @@
+{% extends "layout.html" %}
+{% block title %}admin — kitgit{% endblock %}
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">admin</span>
+  <h1 class="kg-display">site settings</h1>
+
+  <form class="kg-form" method="post" action="/admin/motd" style="margin:1.5rem 0 2.5rem;max-width:36rem">
+    <label>
+      message of the day
+      <textarea name="motd" rows="3" placeholder="Shown on the frontpage">{{ motd }}</textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">save MOTD</button>
+    </div>
+  </form>
+
+  <h2 class="kg-title">site admins</h2>
+  <p class="kg-muted">Pick who can open this panel. The first successful login becomes admin automatically if none exist.</p>
+
+  <ul class="kg-list" style="margin-top:1.5rem">
+    {% for u in users %}
+    <li>
+      <div>
+        <a href="/{{ u.username }}">{{ u.username }}</a>
+        <span class="kg-meta">{{ u.email }}{% if u.is_site_admin %} · admin{% endif %}</span>
+      </div>
+      <form method="post" action="/admin/users" style="display:inline">
+        <input type="hidden" name="user_id" value="{{ u.id }}" />
+        {% if u.is_site_admin %}
+          <input type="hidden" name="is_site_admin" value="false" />
+          <button class="kg-btn kg-btn--ghost" type="submit">revoke admin</button>
+        {% else %}
+          <input type="hidden" name="is_site_admin" value="true" />
+          <button class="kg-btn" type="submit">make admin</button>
+        {% endif %}
+      </form>
+    </li>
+    {% else %}
+    <li><span class="kg-muted">no users yet — create an account or log in first.</span></li>
+    {% endfor %}
+  </ul>
+</section>
+{% endblock %}
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..9fcedc8
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ status }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">error</span>
+  <h1 class="kg-display">{{ status }}</h1>
+  <p class="kg-muted">{{ message }}</p>
+  <p><a class="kg-btn kg-btn--ghost" href="/">home</a></p>
+</section>
+{% endblock %}
diff --git a/templates/explore.html b/templates/explore.html
new file mode 100644
index 0000000..63baa33
--- /dev/null
+++ b/templates/explore.html
@@ -0,0 +1,38 @@
+{% extends "layout.html" %}
+
+{% block title %}explore — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">explore</span>
+  <h1 class="kg-display">public repositories</h1>
+  <form class="kg-explore-search" method="get" action="/explore" role="search">
+    <label class="kg-sr-only" for="q">Search</label>
+    <input id="q" type="search" name="q" value="{{ query }}" placeholder="Search name, description, owner…" />
+    <button class="kg-btn" type="submit">search</button>
+  </form>
+
+  {% if repos.is_empty() %}
+    <p class="kg-muted" style="margin-top:1.5rem">
+      {% if !query.is_empty() %}No public repositories match “{{ query }}”.{% else %}No public repositories yet.{% endif %}
+    </p>
+  {% else %}
+    <ul class="kg-list" style="margin-top:1.5rem">
+      {% for item in repos %}
+      <li>
+        <div>
+          <a href="/{{ item.owner }}/{{ item.repo.name }}">
+            <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span>
+            {{ item.owner }}/{{ item.repo.name }}
+          </a>
+          {% if !item.repo.description.is_empty() %}
+            <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p>
+          {% endif %}
+        </div>
+        <span class="kg-meta">{{ item.repo.updated_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000..27d55ce
--- /dev/null
+++ b/templates/home.html
@@ -0,0 +1,81 @@
+{% extends "layout.html" %}
+
+{% block title %}kitgit{% endblock %}
+
+{% block content %}
+{% if let Some(u) = viewer %}
+<section class="kg-section">
+  <h1 class="kg-display">kitgit</h1>
+  {% if !motd.is_empty() %}
+    <p class="kg-motd">{{ motd }}</p>
+  {% endif %}
+</section>
+
+<section class="kg-section">
+  <span class="kg-kicker">your repositories</span>
+  {% if my_repos.is_empty() %}
+    <p class="kg-muted">No repositories yet. <a href="/new">Create one</a>.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for r in my_repos %}
+      <li>
+        <a href="/{{ u.username }}/{{ r.name }}">
+          <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span>
+          {{ u.username }}/{{ r.name }}
+        </a>
+        <span class="kg-meta">{% if r.visibility == "private" %}private{% else %}public{% endif %}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+
+<section class="kg-section">
+  <span class="kg-kicker">your activity</span>
+  {% if activities.is_empty() %}
+    <p class="kg-muted">No activity yet.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for a in activities %}
+      <li>
+        <span class="kg-activity">
+          {% if let Some(avatar) = a.actor_avatar %}
+            <img class="kg-avatar kg-avatar--sm" src="{{ avatar }}" alt="" />
+          {% endif %}
+          {% if let Some(name) = a.actor_username %}
+            <a href="/{{ name }}">{{ name }}</a>
+          {% endif %}
+          <span>
+            {{ a.summary }}{% if let Some(label) = a.ref_label %}{% if let Some(href) = a.ref_href %}<a href="{{ href }}">{{ label }}</a>{% else %}{{ label }}{% endif %}{% endif %}
+          </span>
+          {% if let Some(owner) = a.repo_owner %}
+            {% if let Some(rname) = a.repo_name %}
+              <a href="/{{ owner }}/{{ rname }}">{{ owner }}/{{ rname }}</a>
+            {% endif %}
+          {% endif %}
+        </span>
+        <span class="kg-meta">{{ a.created_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% else %}
+<section class="kg-landing">
+  <p class="kg-landing__brand">
+    <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+    <span class="kg-mark__name">kitgit</span>
+  </p>
+  {% if !motd.is_empty() %}
+    <p class="kg-motd">{{ motd }}</p>
+  {% else %}
+    <p class="kg-muted">Self-hosted git for your team.</p>
+  {% endif %}
+  <div class="kg-actions kg-landing__cta">
+    <a class="kg-btn" href="/auth/login">log in</a>
+    <a class="kg-btn kg-btn--ghost" href="/auth/signup">sign up</a>
+    <a class="kg-btn kg-btn--ghost" href="/explore">explore</a>
+  </div>
+</section>
+{% endif %}
+{% endblock %}
diff --git a/templates/issue_new.html b/templates/issue_new.html
new file mode 100644
index 0000000..0319760
--- /dev/null
+++ b/templates/issue_new.html
@@ -0,0 +1,29 @@
+{% extends "layout.html" %}
+
+{% block title %}new issue · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">New issue</h1>
+  {% let tab = "issues" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues">
+    <label>
+      title
+      <input type="text" name="title" required autofocus />
+    </label>
+    <label>
+      body
+      <textarea name="body" rows="8"></textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">create</button>
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues">cancel</a>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/issue_view.html b/templates/issue_view.html
new file mode 100644
index 0000000..2158d27
--- /dev/null
+++ b/templates/issue_view.html
@@ -0,0 +1,57 @@
+{% extends "layout.html" %}
+
+{% block title %}#{{ issue.number }} {{ issue.title }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <span class="kg-badge {% if issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ issue.state }}</span>
+    #{{ issue.number }} {{ issue.title }}
+  </h1>
+  {% let tab = "issues" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <article class="kg-comment">
+    <header>
+      <span>
+        <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" />
+        <a href="/{{ author.username }}">{{ author.username }}</a>
+      </span>
+      <span>{{ issue.created_at }}</span>
+    </header>
+    <div class="kg-readme">{{ body_html|safe }}</div>
+  </article>
+
+  {% for c in comments %}
+  <article class="kg-comment">
+    <header>
+      <span>
+        <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" />
+        <a href="/{{ c.author.username }}">{{ c.author.username }}</a>
+      </span>
+      <span>{{ c.created_at }}</span>
+    </header>
+    <div class="kg-readme">{{ c.body_html|safe }}</div>
+    {% include "partials/reactions.html" %}
+  </article>
+  {% endfor %}
+
+  {% if viewer.is_some() %}
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">
+      <label>
+        comment
+        <textarea name="body" rows="4" required></textarea>
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">comment</button>
+        {% if issue.state == "open" %}
+          <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/close" formmethod="post" type="submit" formnovalidate>close</button>
+        {% else %}
+          <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/reopen" formmethod="post" type="submit" formnovalidate>reopen</button>
+        {% endif %}
+      </div>
+    </form>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/issues_list.html b/templates/issues_list.html
new file mode 100644
index 0000000..6c1629d
--- /dev/null
+++ b/templates/issues_list.html
@@ -0,0 +1,39 @@
+{% extends "layout.html" %}
+
+{% block title %}issues · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · issues
+  </h1>
+  {% let tab = "issues" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <div class="kg-actions">
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
+    {% if viewer.is_some() %}
+      <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/issues/new">new issue</a>
+    {% endif %}
+  </div>
+
+  {% if issues.is_empty() %}
+    <p class="kg-muted">No {{ state_filter }} issues.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for issue in issues %}
+      <li>
+        <span>
+          <span class="kg-badge {% if issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ issue.state }}</span>
+          <a href="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a>
+        </span>
+        <span class="kg-meta">{{ issue.updated_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/keys_settings.html b/templates/keys_settings.html
new file mode 100644
index 0000000..71251aa
--- /dev/null
+++ b/templates/keys_settings.html
@@ -0,0 +1,88 @@
+{% extends "layout.html" %}
+
+{% block title %}ssh / gpg keys — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">settings</span>
+  <h1 class="kg-title">SSH &amp; GPG keys</h1>
+  <nav class="kg-tabs" aria-label="Settings">
+    <a href="/settings/profile">profile</a>
+    <a href="/settings/account">account</a>
+    <a href="/settings/mfa">mfa</a>
+    <a href="/settings/keys" aria-current="page">ssh / gpg</a>
+  </nav>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+
+  <span class="kg-kicker" style="margin-top:1.5rem">SSH keys</span>
+  {% if !keys.is_empty() %}
+    <ul class="kg-list">
+      {% for key in keys %}
+      <li>
+        <span>
+          <strong>{{ key.name }}</strong>
+          <span class="kg-meta">{{ key.fingerprint }}</span>
+        </span>
+        <form method="post" action="/settings/keys/{{ key.id }}/delete">
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
+        </form>
+      </li>
+      {% endfor %}
+    </ul>
+  {% else %}
+    <p class="kg-muted">No SSH keys yet.</p>
+  {% endif %}
+
+  <h2 class="kg-title" style="margin-top:2rem">Add SSH key</h2>
+  <form class="kg-form" method="post" action="/settings/keys">
+    <label>
+      name
+      <input type="text" name="name" required />
+    </label>
+    <label>
+      public key
+      <textarea name="public_key" rows="4" required placeholder="ssh-ed25519 AAAA…"></textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">add</button>
+    </div>
+  </form>
+
+  <span class="kg-kicker" style="margin-top:2.5rem">GPG keys</span>
+  <p class="kg-muted">Used for commit verification. Vigilant mode flags unsigned commits on your profile.</p>
+  {% if !gpg_keys.is_empty() %}
+    <ul class="kg-list">
+      {% for key in gpg_keys %}
+      <li>
+        <span>
+          <strong>{{ key.name }}</strong>
+          <span class="kg-meta">{{ key.fingerprint }}</span>
+        </span>
+        <form method="post" action="/settings/gpg/{{ key.id }}/delete">
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
+        </form>
+      </li>
+      {% endfor %}
+    </ul>
+  {% else %}
+    <p class="kg-muted">No GPG keys yet.</p>
+  {% endif %}
+
+  <h2 class="kg-title" style="margin-top:2rem">Add GPG key</h2>
+  <form class="kg-form" method="post" action="/settings/gpg">
+    <label>
+      name
+      <input type="text" name="name" required value="gpg" />
+    </label>
+    <label>
+      public key
+      <textarea name="public_key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">add</button>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..b1a3563
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>{% block title %}kitgit{% endblock %}</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
+  <link rel="stylesheet" href="/static/brand.css" />
+  {% block head %}{% endblock %}
+</head>
+<body>
+  <div class="kg-shell">
+    <header class="kg-bar">
+      <a class="kg-mark" href="/">
+        <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+        <span class="kg-mark__name">kitgit</span>
+      </a>
+      <nav aria-label="Primary">
+        <a href="/explore"><span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> explore</a>
+        {% if let Some(u) = viewer %}
+          <a href="/new"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> new</a>
+          {% if u.is_site_admin %}<a href="/admin"><span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> admin</a>{% endif %}
+          <a href="/settings/profile"><span class="kg-icon" style="--icon:url('/static/icons/user.svg')"></span> settings</a>
+          <a class="kg-userchip" href="/{{ u.username }}">
+            <img class="kg-avatar kg-avatar--sm" src="/avatars/{{ u.id }}?v={{ u.updated_at.timestamp() }}" alt="" />
+            {{ u.username }}
+          </a>
+          <a href="/auth/logout"><span class="kg-icon" style="--icon:url('/static/icons/log-out.svg')"></span> logout</a>
+        {% else %}
+          <a href="/auth/login"><span class="kg-icon" style="--icon:url('/static/icons/log-in.svg')"></span> login</a>
+          <a href="/auth/signup"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> signup</a>
+        {% endif %}
+      </nav>
+    </header>
+    <main>
+      {% block content %}{% endblock %}
+    </main>
+    <footer class="kg-footer">kitgit</footer>
+  </div>
+  <script>
+  document.addEventListener('click', (e) => {
+    const btn = e.target.closest('[data-copy]');
+    if (!btn) return;
+    const sel = btn.getAttribute('data-copy');
+    const el = sel ? document.querySelector(sel) : null;
+    const text = el ? (el.value || el.textContent) : btn.getAttribute('data-copy-text');
+    if (text) navigator.clipboard.writeText(text.trim());
+  });
+  </script>
+</body>
+</html>
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..918b8e9
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,32 @@
+{% extends "layout.html" %}
+
+{% block title %}log in — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section kg-auth">
+  <p class="kg-landing__brand">
+    <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+    <span class="kg-mark__name">kitgit</span>
+  </p>
+  <h1 class="kg-title">log in</h1>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/auth/login" autocomplete="on">
+    <label>
+      username
+      <input type="text" name="username" required autofocus autocomplete="username" />
+    </label>
+    <label>
+      password
+      <input type="password" name="password" required autocomplete="current-password" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">log in</button>
+    </div>
+  </form>
+  <p class="kg-meta" style="margin-top:1.25rem">
+    No account? <a href="/auth/signup">Sign up</a>
+  </p>
+</section>
+{% endblock %}
diff --git a/templates/mfa_challenge.html b/templates/mfa_challenge.html
new file mode 100644
index 0000000..d85411c
--- /dev/null
+++ b/templates/mfa_challenge.html
@@ -0,0 +1,27 @@
+{% extends "layout.html" %}
+
+{% block title %}verify — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section kg-auth">
+  <p class="kg-landing__brand">
+    <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+    <span class="kg-mark__name">kitgit</span>
+  </p>
+  <h1 class="kg-title">two-factor authentication</h1>
+  <p class="kg-muted">Enter a code from your authenticator app, or a recovery code.</p>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/auth/mfa" autocomplete="one-time-code">
+    <label>
+      authentication code
+      <input type="text" name="code" required autofocus inputmode="numeric" autocomplete="one-time-code" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">verify</button>
+      <a class="kg-btn kg-btn--ghost" href="/auth/login">back</a>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/mfa_settings.html b/templates/mfa_settings.html
new file mode 100644
index 0000000..7d38483
--- /dev/null
+++ b/templates/mfa_settings.html
@@ -0,0 +1,78 @@
+{% extends "layout.html" %}
+
+{% block title %}MFA settings — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">settings</span>
+  <h1 class="kg-title">Two-factor authentication</h1>
+  <nav class="kg-tabs" aria-label="Settings">
+    <a href="/settings/profile">profile</a>
+    <a href="/settings/account">account</a>
+    <a href="/settings/mfa" aria-current="page">mfa</a>
+    <a href="/settings/keys">ssh / gpg</a>
+  </nav>
+
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  {% if let Some(msg) = message %}
+    <div class="kg-flash">{{ msg }}</div>
+  {% endif %}
+
+  <div class="kg-settings-section" style="border-top:0;padding-top:0;margin-top:var(--kg-space-4)">
+    {% if enabled %}
+      <p class="kg-muted">Authenticator app MFA is enabled for your account.</p>
+      <p class="kg-meta">{{ recovery_remaining }} recovery code(s) remaining.</p>
+      <form class="kg-form" method="post" action="/settings/mfa/disable" style="margin-top:1rem">
+        <label>
+          confirm with authenticator or recovery code
+          <input type="text" name="code" required autocomplete="one-time-code" />
+        </label>
+        <div class="kg-actions">
+          <button class="kg-btn kg-btn--danger" type="submit">disable MFA</button>
+          <a class="kg-btn kg-btn--ghost" href="/settings/account">back to account</a>
+        </div>
+      </form>
+    {% else if pending %}
+      <p class="kg-muted">Scan this QR code with your authenticator app, then enter a code to confirm.</p>
+      {% if let Some(qr) = qr_data_uri %}
+        <p><img src="{{ qr }}" alt="MFA QR code" width="180" height="180" style="image-rendering:pixelated;background:#fff;padding:0.5rem" /></p>
+      {% endif %}
+      {% if let Some(sec) = secret %}
+        <p class="kg-meta">Or enter this secret manually: <code>{{ sec }}</code></p>
+      {% endif %}
+      <form class="kg-form" method="post" action="/settings/mfa/confirm" style="margin-top:1rem">
+        <label>
+          confirmation code
+          <input type="text" name="code" required autofocus inputmode="numeric" autocomplete="one-time-code" />
+        </label>
+        <div class="kg-actions">
+          <button class="kg-btn" type="submit">confirm and enable</button>
+          <a class="kg-btn kg-btn--ghost" href="/settings/account">cancel</a>
+        </div>
+      </form>
+    {% else %}
+      <p class="kg-muted">Protect your account with a time-based one-time password (TOTP) from an authenticator app.</p>
+      <form method="post" action="/settings/mfa/enroll">
+        <div class="kg-actions">
+          <button class="kg-btn" type="submit">set up authenticator</button>
+          <a class="kg-btn kg-btn--ghost" href="/settings/account">back to account</a>
+        </div>
+      </form>
+    {% endif %}
+  </div>
+
+  {% if let Some(codes) = recovery_codes %}
+  <div class="kg-settings-section">
+    <span class="kg-kicker">recovery codes</span>
+    <p class="kg-muted">Save these codes somewhere safe. Each can be used once if you lose your authenticator. They will not be shown again.</p>
+    <ul class="kg-list">
+      {% for c in codes %}
+      <li><code>{{ c }}</code></li>
+      {% endfor %}
+    </ul>
+  </div>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/new_repo.html b/templates/new_repo.html
new file mode 100644
index 0000000..7894f84
--- /dev/null
+++ b/templates/new_repo.html
@@ -0,0 +1,33 @@
+{% extends "layout.html" %}
+
+{% block title %}new repository — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">new</span>
+  <h1 class="kg-title">New repository</h1>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/new">
+    <label>
+      name
+      <input type="text" name="name" required maxlength="100" pattern="[A-Za-z0-9._-]+" autofocus />
+    </label>
+    <label>
+      description
+      <textarea name="description" rows="3"></textarea>
+    </label>
+    <label>
+      visibility
+      <select name="visibility">
+        <option value="public">public</option>
+        <option value="private">private</option>
+      </select>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">create</button>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/partials/branch_bar.html b/templates/partials/branch_bar.html
new file mode 100644
index 0000000..1468df8
--- /dev/null
+++ b/templates/partials/branch_bar.html
@@ -0,0 +1,58 @@
+{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access (optional for upload), upload_path (optional) #}
+<div class="kg-branchbar">
+  <div class="kg-branchbar__left">
+    <a class="kg-branchbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches">
+      <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
+      {{ branch }}
+    </a>
+    {% if let Some(c) = latest_commit %}
+    <div class="kg-branchbar__commit">
+      <a class="kg-branchbar__sha" href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits">
+        <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
+        {{ c.short_id }}
+      </a>
+      <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a>
+    </div>
+    {% endif %}
+  </div>
+  <div class="kg-branchbar__actions">
+    {% if upload_path != "" %}
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ upload_path }}" title="Download this folder as ZIP">
+      <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+      download folder
+    </a>
+    {% else %}
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}" title="Download repository as ZIP">
+      <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+      download
+    </a>
+    {% endif %}
+    {% if access.can_write() %}
+    <details class="kg-dropdown">
+      <summary class="kg-btn kg-btn--ghost">
+        <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span>
+        upload
+        <span class="kg-icon" style="--icon:url('/static/icons/chevron-down.svg')"></span>
+      </summary>
+      <div class="kg-dropdown__panel kg-dropdown__panel--upload">
+        <form class="kg-upload" method="post" action="/{{ owner.username }}/{{ repo.name }}/upload" enctype="multipart/form-data">
+          <input type="hidden" name="branch" value="{{ branch }}" />
+          <input type="hidden" name="path" value="{{ upload_path }}" />
+          <label class="kg-upload__file">
+            <span class="kg-meta">File</span>
+            <input type="file" name="file" required />
+          </label>
+          <label class="kg-upload__message">
+            <span class="kg-meta">Commit message</span>
+            <input type="text" name="message" placeholder="Commit message" value="Upload file" />
+          </label>
+          <button class="kg-btn" type="submit">
+            <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span>
+            commit file
+          </button>
+        </form>
+      </div>
+    </details>
+    {% endif %}
+  </div>
+</div>
diff --git a/templates/partials/katex.html b/templates/partials/katex.html
new file mode 100644
index 0000000..700b3a6
--- /dev/null
+++ b/templates/partials/katex.html
@@ -0,0 +1,39 @@
+{# KaTeX for README / markdown — uses KaTeX's bundled Computer Modern fonts #}
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
+<style>
+  .katex-display {
+    margin: 1rem 0;
+    overflow-x: auto;
+    overflow-y: hidden;
+  }
+  /* Size bump only — font-face families come from katex.min.css (Computer Modern). */
+  .kg-readme .katex,
+  .kg-md .katex {
+    font-size: 1.1em;
+  }
+</style>
+<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
+  onload="window.__kitgitRenderKatex && window.__kitgitRenderKatex()"></script>
+<script>
+window.__kitgitRenderKatex = function () {
+  if (typeof renderMathInElement !== "function") return;
+  var roots = document.querySelectorAll(".kg-readme, .kg-md");
+  if (!roots.length) roots = [document.body];
+  var opts = {
+    delimiters: [
+      { left: "$$", right: "$$", display: true },
+      { left: "\\[", right: "\\]", display: true },
+      { left: "$", right: "$", display: false },
+      { left: "\\(", right: "\\)", display: false }
+    ],
+    throwOnError: false,
+    strict: "ignore",
+    ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code", "option"]
+  };
+  roots.forEach(function (el) { renderMathInElement(el, opts); });
+};
+document.addEventListener("DOMContentLoaded", function () {
+  if (typeof renderMathInElement === "function") window.__kitgitRenderKatex();
+});
+</script>
diff --git a/templates/partials/reactions.html b/templates/partials/reactions.html
new file mode 100644
index 0000000..7f55065
--- /dev/null
+++ b/templates/partials/reactions.html
@@ -0,0 +1,44 @@
+{# Expects: c (CommentView), owner, repo, viewer #}
+{% if viewer.is_some() || !c.reactions.is_empty() %}
+<div class="kg-reactions">
+  {% for r in c.reactions %}
+  <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react" style="display:inline">
+    <input type="hidden" name="emoji" value="{{ r.emoji }}" />
+    <button class="kg-reaction" type="submit" aria-pressed="{% if r.mine %}true{% else %}false{% endif %}" title="{{ r.emoji }}">
+      {{ r.label }} {{ r.count }}
+    </button>
+  </form>
+  {% endfor %}
+  {% if viewer.is_some() %}
+  <details class="kg-dropdown">
+    <summary class="kg-reaction" title="Add reaction">+</summary>
+    <div class="kg-dropdown__panel">
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="+1" />
+        <button class="kg-dropdown__item" type="submit">+1</button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="-1" />
+        <button class="kg-dropdown__item" type="submit">-1</button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="heart" />
+        <button class="kg-dropdown__item" type="submit">heart</button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="laugh" />
+        <button class="kg-dropdown__item" type="submit">laugh</button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="rocket" />
+        <button class="kg-dropdown__item" type="submit">rocket</button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react">
+        <input type="hidden" name="emoji" value="eyes" />
+        <button class="kg-dropdown__item" type="submit">eyes</button>
+      </form>
+    </div>
+  </details>
+  {% endif %}
+</div>
+{% endif %}
diff --git a/templates/partials/repo_tabs.html b/templates/partials/repo_tabs.html
new file mode 100644
index 0000000..5ca44db
--- /dev/null
+++ b/templates/partials/repo_tabs.html
@@ -0,0 +1,61 @@
+{# Expects: owner, repo, access, tab, clone_http, clone_ssh, archive_ref #}
+<nav class="kg-tabs" aria-label="Repository">
+  <div class="kg-tabs__links">
+    <a href="/{{ owner.username }}/{{ repo.name }}"{% if tab == "code" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> code
+    </a>
+    {% if repo.issues_enabled %}
+    <a href="/{{ owner.username }}/{{ repo.name }}/issues"{% if tab == "issues" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/circle.svg')"></span> issues
+    </a>
+    {% endif %}
+    {% if repo.pulls_enabled %}
+    <a href="/{{ owner.username }}/{{ repo.name }}/pulls"{% if tab == "pulls" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/git-pull-request.svg')"></span> pulls
+    </a>
+    {% endif %}
+    {% if repo.releases_enabled %}
+    <a href="/{{ owner.username }}/{{ repo.name }}/releases"{% if tab == "releases" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/package.svg')"></span> releases
+    </a>
+    {% endif %}
+    <a href="/{{ owner.username }}/{{ repo.name }}/commits"{% if tab == "commits" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/history.svg')"></span> commits
+    </a>
+    <a href="/{{ owner.username }}/{{ repo.name }}/branches"{% if tab == "branches" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> branches
+    </a>
+    {% if access.can_admin() %}
+    <a href="/{{ owner.username }}/{{ repo.name }}/settings"{% if tab == "settings" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> settings
+    </a>
+    {% endif %}
+  </div>
+  <details class="kg-dropdown">
+    <summary class="kg-btn kg-btn--ghost">
+      <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+      clone
+      <span class="kg-icon" style="--icon:url('/static/icons/chevron-down.svg')"></span>
+    </summary>
+    <div class="kg-dropdown__panel">
+      <a class="kg-dropdown__item" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ archive_ref }}">
+        <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+        Download ZIP
+      </a>
+      <div class="kg-clone-row">
+        <label for="clone-http">HTTP</label>
+        <input id="clone-http" type="text" readonly value="{{ clone_http }}" />
+        <button type="button" class="kg-btn kg-btn--ghost" data-copy="#clone-http" title="Copy">
+          <span class="kg-icon" style="--icon:url('/static/icons/copy.svg')"></span>
+        </button>
+      </div>
+      <div class="kg-clone-row">
+        <label for="clone-ssh">SSH</label>
+        <input id="clone-ssh" type="text" readonly value="{{ clone_ssh }}" />
+        <button type="button" class="kg-btn kg-btn--ghost" data-copy="#clone-ssh" title="Copy">
+          <span class="kg-icon" style="--icon:url('/static/icons/copy.svg')"></span>
+        </button>
+      </div>
+    </div>
+  </details>
+</nav>
diff --git a/templates/profile.html b/templates/profile.html
new file mode 100644
index 0000000..c46436a
--- /dev/null
+++ b/templates/profile.html
@@ -0,0 +1,121 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ profile.username }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <div class="kg-profile-head">
+    <img class="kg-avatar kg-avatar--lg" src="{{ avatar_url }}" alt="" />
+    <div>
+      {% if !profile.display_name.is_empty() %}
+        <h1 class="kg-display">{{ profile.display_name }}</h1>
+        <p class="kg-muted">@{{ profile.username }}</p>
+      {% else %}
+        <h1 class="kg-display">{{ profile.username }}</h1>
+      {% endif %}
+      {% if profile.show_email && !profile.email.is_empty() %}
+        <p class="kg-meta">{{ profile.email }}</p>
+      {% endif %}
+      {% if !profile.bio.is_empty() %}
+        <p>{{ profile.bio }}</p>
+      {% endif %}
+      {% if is_self %}
+        <p><a class="kg-btn kg-btn--ghost" href="/settings/profile">edit profile</a></p>
+      {% endif %}
+    </div>
+  </div>
+
+  <span class="kg-kicker">contributions</span>
+  {% if !has_activity %}
+    <p class="kg-contrib-empty">No recorded git activity yet.</p>
+  {% endif %}
+  <div class="kg-graph" aria-label="Commit graph">
+    {% for day in graph %}
+      {% if day.count == 0 %}
+        <i title="{{ day.day }}: 0"></i>
+      {% else if day.count == 1 %}
+        <i data-n="1" title="{{ day.day }}: {{ day.count }}"></i>
+      {% else if day.count < 4 %}
+        <i data-n="2" title="{{ day.day }}: {{ day.count }}"></i>
+      {% else if day.count < 7 %}
+        <i data-n="3" title="{{ day.day }}: {{ day.count }}"></i>
+      {% else %}
+        <i data-n="4" title="{{ day.day }}: {{ day.count }}"></i>
+      {% endif %}
+    {% endfor %}
+  </div>
+
+  <span class="kg-kicker">repositories</span>
+  {% if repos.is_empty() %}
+    <p class="kg-muted">No repositories.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for r in repos %}
+      <li>
+        <span>
+          <a href="/{{ profile.username }}/{{ r.name }}">{{ r.name }}</a>
+          {% if r.visibility == "private" %}<span class="kg-badge">private</span>{% endif %}
+          {% if r.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %}
+          {% if !r.description.is_empty() %}
+            <span class="kg-muted"> — {{ r.description }}</span>
+          {% endif %}
+        </span>
+        <span class="kg-meta">{{ r.updated_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+
+  <span class="kg-kicker">starred</span>
+  {% if starred.is_empty() %}
+    <p class="kg-muted">No public starred repositories.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for s in starred %}
+      <li>
+        <span>
+          <a href="/{{ s.owner }}/{{ s.repo.name }}">{{ s.owner }}/{{ s.repo.name }}</a>
+          {% if s.repo.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %}
+          {% if !s.repo.description.is_empty() %}
+            <span class="kg-muted"> — {{ s.repo.description }}</span>
+          {% endif %}
+        </span>
+        <span class="kg-meta">
+          <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span>
+          {{ s.repo.stars_count }}
+        </span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+
+  <span class="kg-kicker">watched activity</span>
+  {% if watched_activity.is_empty() %}
+    <p class="kg-muted">No recent activity from watched public repositories.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for a in watched_activity %}
+      <li>
+        <span class="kg-activity">
+          {% if let Some(avatar) = a.actor_avatar %}
+            <img class="kg-avatar kg-avatar--sm" src="{{ avatar }}" alt="" />
+          {% endif %}
+          {% if let Some(name) = a.actor_username %}
+            <a href="/{{ name }}">{{ name }}</a>
+          {% endif %}
+          <span>
+            {{ a.summary }}{% if let Some(label) = a.ref_label %}{% if let Some(href) = a.ref_href %}<a href="{{ href }}">{{ label }}</a>{% else %}{{ label }}{% endif %}{% endif %}
+          </span>
+          {% if let Some(owner) = a.repo_owner %}
+            {% if let Some(rname) = a.repo_name %}
+              <a href="/{{ owner }}/{{ rname }}">{{ owner }}/{{ rname }}</a>
+            {% endif %}
+          {% endif %}
+        </span>
+        <span class="kg-meta">{{ a.created_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/profile_settings.html b/templates/profile_settings.html
new file mode 100644
index 0000000..ce0b3ae
--- /dev/null
+++ b/templates/profile_settings.html
@@ -0,0 +1,45 @@
+{% extends "layout.html" %}
+
+{% block title %}profile settings — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <span class="kg-kicker">settings</span>
+  <h1 class="kg-title">Profile</h1>
+  <nav class="kg-tabs" aria-label="Settings">
+    <a href="/settings/profile" aria-current="page">profile</a>
+    <a href="/settings/account">account</a>
+    <a href="/settings/mfa">mfa</a>
+    <a href="/settings/keys">ssh / gpg</a>
+  </nav>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <div class="kg-profile-head">
+    <img class="kg-avatar kg-avatar--lg" src="{{ avatar_url }}" alt="" />
+    <div>
+      <p class="kg-title">{{ user.username }}</p>
+      {% if user.show_email && !user.email.is_empty() %}
+      <p class="kg-muted">{{ user.email }}</p>
+      {% endif %}
+    </div>
+  </div>
+  <form class="kg-form" method="post" action="/settings/profile" enctype="multipart/form-data">
+    <label>
+      display name
+      <input type="text" name="display_name" value="{{ user.display_name }}" />
+    </label>
+    <label>
+      bio
+      <textarea name="bio" rows="4">{{ user.bio }}</textarea>
+    </label>
+    <label>
+      avatar
+      <input type="file" name="avatar" accept="image/*" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">save</button>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/pull_new.html b/templates/pull_new.html
new file mode 100644
index 0000000..21d97f5
--- /dev/null
+++ b/templates/pull_new.html
@@ -0,0 +1,45 @@
+{% extends "layout.html" %}
+
+{% block title %}new pull · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">New pull request</h1>
+  {% let tab = "pulls" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls">
+    <label>
+      title
+      <input type="text" name="title" required autofocus />
+    </label>
+    <label>
+      source branch
+      <select name="source_branch" required>
+        {% for b in branches %}
+          <option value="{{ b }}">{{ b }}</option>
+        {% endfor %}
+      </select>
+    </label>
+    <label>
+      target branch
+      <select name="target_branch" required>
+        {% for b in branches %}
+          <option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option>
+        {% endfor %}
+      </select>
+    </label>
+    <label>
+      body
+      <textarea name="body" rows="8"></textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">create</button>
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls">cancel</a>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/pull_view.html b/templates/pull_view.html
new file mode 100644
index 0000000..34c5028
--- /dev/null
+++ b/templates/pull_view.html
@@ -0,0 +1,160 @@
+{% extends "layout.html" %}
+
+{% block title %}#{{ pull.number }} {{ pull.title }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <span class="kg-badge {% if pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ pull.state }}</span>
+    #{{ pull.number }} {{ pull.title }}
+  </h1>
+  <p class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</p>
+  {% let pr_tab = tab %}
+  {% let tab = "pulls" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <nav class="kg-pr-tabs" aria-label="Pull request">
+    <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=conversation"{% if pr_tab == "conversation" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/circle.svg')"></span>
+      conversation
+      <span class="kg-pr-tabs__count">{{ conversation_count }}</span>
+    </a>
+    <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=commits"{% if pr_tab == "commits" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/history.svg')"></span>
+      commits
+      <span class="kg-pr-tabs__count">{{ commits.len() }}</span>
+    </a>
+    <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files"{% if pr_tab == "files" %} aria-current="page"{% endif %}>
+      <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
+      files
+      <span class="kg-pr-tabs__count">{{ diff_files.len() }}</span>
+    </a>
+  </nav>
+
+  {% if pr_tab == "conversation" %}
+  <article class="kg-comment">
+    <header>
+      <span>
+        <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" />
+        <a href="/{{ author.username }}">{{ author.username }}</a>
+      </span>
+      <span>{{ pull.created_at }}</span>
+    </header>
+    <div class="kg-readme">{{ body_html|safe }}</div>
+  </article>
+
+  {% for c in comments %}
+  <article class="kg-comment">
+    <header>
+      <span>
+        <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" />
+        <a href="/{{ c.author.username }}">{{ c.author.username }}</a>
+      </span>
+      <span>{{ c.created_at }}</span>
+    </header>
+    <div class="kg-readme">{{ c.body_html|safe }}</div>
+    {% include "partials/reactions.html" %}
+  </article>
+  {% endfor %}
+
+  {% if viewer.is_some() %}
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">
+      <label>
+        comment
+        <textarea name="body" rows="4" required></textarea>
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">comment</button>
+        {% if pull.state == "open" %}
+          <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/close" formmethod="post" type="submit" formnovalidate>close</button>
+        {% endif %}
+      </div>
+    </form>
+  {% endif %}
+
+  {% if can_merge && !merge_styles.is_empty() %}
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/merge">
+      <label>
+        merge style
+        <select name="style">
+          {% for style in merge_styles %}
+            <option value="{{ style }}"{% if style.as_str() == repo.default_merge_style.as_str() %} selected{% endif %}>{{ style }}</option>
+          {% endfor %}
+        </select>
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">merge</button>
+      </div>
+    </form>
+  {% endif %}
+  {% else if pr_tab == "commits" %}
+    {% if commits.is_empty() %}
+      <p class="kg-muted">No commits on this pull request.</p>
+    {% else %}
+      <ul class="kg-list">
+        {% for c in commits %}
+        <li>
+          <span>
+            <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"><code>{{ c.short_id }}</code></a>
+            <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
+          </span>
+          <span class="kg-meta">{{ c.author }}</span>
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+  {% else %}
+    {% if diff_files.is_empty() %}
+      <p class="kg-muted">No file changes.</p>
+    {% else %}
+      <div class="kg-pr-files">
+        <aside class="kg-pr-files__nav" aria-label="Changed files">
+          <p class="kg-pr-files__nav-title">{{ diff_files.len() }} files</p>
+          <ul>
+            {% for f in diff_files %}
+            <li>
+              <a href="#{{ f.anchor }}">
+                <span class="kg-pr-files__path">{{ f.path }}</span>
+                <span class="kg-pr-files__stat">
+                  {% if f.binary %}
+                    binary
+                  {% else %}
+                    <span class="kg-pr-files__add">+{{ f.additions }}</span>
+                    <span class="kg-pr-files__del">−{{ f.deletions }}</span>
+                  {% endif %}
+                </span>
+              </a>
+            </li>
+            {% endfor %}
+          </ul>
+        </aside>
+        <div class="kg-pr-files__panels">
+          {% for f in diff_files %}
+          <section class="kg-diff-file" id="{{ f.anchor }}">
+            <header class="kg-diff-file__head">
+              <strong>{{ f.path }}</strong>
+              <span class="kg-meta">
+                {% if f.binary %}
+                  binary
+                {% else %}
+                  <span class="kg-pr-files__add">+{{ f.additions }}</span>
+                  <span class="kg-pr-files__del">−{{ f.deletions }}</span>
+                {% endif %}
+              </span>
+            </header>
+            {{ f.html|safe }}
+            {% if f.truncated %}
+              <p class="kg-diff__truncated">
+                Large diffs are not rendered by default.
+                Showing the first 50 of {{ f.total_lines }} lines.
+              </p>
+            {% endif %}
+          </section>
+          {% endfor %}
+        </div>
+      </div>
+    {% endif %}
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/pulls_list.html b/templates/pulls_list.html
new file mode 100644
index 0000000..69425f5
--- /dev/null
+++ b/templates/pulls_list.html
@@ -0,0 +1,41 @@
+{% extends "layout.html" %}
+
+{% block title %}pulls · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · pulls
+  </h1>
+  {% let tab = "pulls" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <div class="kg-actions">
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=merged"{% if state_filter == "merged" %} aria-current="page"{% endif %}>merged</a>
+    {% if viewer.is_some() %}
+      <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/pulls/new">new pull</a>
+    {% endif %}
+  </div>
+
+  {% if pulls.is_empty() %}
+    <p class="kg-muted">No {{ state_filter }} pulls.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for pull in pulls %}
+      <li>
+        <span>
+          <span class="kg-badge {% if pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ pull.state }}</span>
+          <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">#{{ pull.number }} {{ pull.title }}</a>
+          <span class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</span>
+        </span>
+        <span class="kg-meta">{{ pull.updated_at }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/release_edit.html b/templates/release_edit.html
new file mode 100644
index 0000000..1f76dbf
--- /dev/null
+++ b/templates/release_edit.html
@@ -0,0 +1,50 @@
+{% extends "layout.html" %}
+
+{% block title %}edit {{ release.tag_name }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">Edit release</h1>
+  {% let tab = "releases" %}
+  {% let archive_ref = release.tag_name.clone() %}
+  {% include "partials/repo_tabs.html" %}
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/edit">
+    <label>
+      tag
+      <input type="text" name="tag_name" required value="{{ release.tag_name }}" />
+    </label>
+    <label>
+      title
+      <input type="text" name="title" required value="{{ release.title }}" />
+    </label>
+    <label>
+      target (retarget tag)
+      <input type="text" name="target" list="release-targets" value="{{ target }}" placeholder="branch, tag, or commit SHA" />
+      <datalist id="release-targets">
+        {% for b in branches %}
+          <option value="{{ b }}"></option>
+        {% endfor %}
+      </datalist>
+    </label>
+    <label>
+      notes
+      <textarea name="body" rows="12" placeholder="Markdown supported">{{ release.body }}</textarea>
+    </label>
+    <label class="kg-check">
+      <input type="checkbox" name="is_prerelease" value="on"{% if release.is_prerelease %} checked{% endif %} />
+      This is a pre-release
+    </label>
+    <label class="kg-check">
+      <input type="checkbox" name="is_draft" value="on"{% if release.is_draft %} checked{% endif %} />
+      Save as draft
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">save changes</button>
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}">cancel</a>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/release_new.html b/templates/release_new.html
new file mode 100644
index 0000000..031112c
--- /dev/null
+++ b/templates/release_new.html
@@ -0,0 +1,50 @@
+{% extends "layout.html" %}
+
+{% block title %}new release · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">New release</h1>
+  {% let tab = "releases" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases">
+    <label>
+      tag
+      <input type="text" name="tag_name" required placeholder="v1.0.0" autofocus value="{{ tag_name }}" />
+    </label>
+    <label>
+      target
+      <input type="text" name="target" list="release-targets" value="{{ target }}" placeholder="branch, tag, or commit SHA" />
+      <datalist id="release-targets">
+        {% for b in branches %}
+          <option value="{{ b }}"></option>
+        {% endfor %}
+      </datalist>
+    </label>
+    <label>
+      title
+      <input type="text" name="title" required value="{{ title }}" />
+    </label>
+    <label>
+      notes
+      <textarea name="body" rows="10" placeholder="Markdown supported">{{ body }}</textarea>
+    </label>
+    <label class="kg-check">
+      <input type="checkbox" name="is_prerelease" value="on"{% if is_prerelease %} checked{% endif %} />
+      This is a pre-release
+    </label>
+    <label class="kg-check">
+      <input type="checkbox" name="is_draft" value="on"{% if is_draft %} checked{% endif %} />
+      Save as draft
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">publish release</button>
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases">cancel</a>
+    </div>
+  </form>
+</section>
+{% endblock %}
diff --git a/templates/release_view.html b/templates/release_view.html
new file mode 100644
index 0000000..a682bcb
--- /dev/null
+++ b/templates/release_view.html
@@ -0,0 +1,104 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ release.tag_name }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <div class="kg-release-head">
+    <div>
+      <h1 class="kg-title">
+        {% if release.is_draft %}<span class="kg-badge">draft</span>{% endif %}
+        {% if release.is_prerelease %}<span class="kg-badge kg-badge--pre">pre-release</span>{% endif %}
+        {{ release.title }}
+      </h1>
+      <p class="kg-meta">
+        <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" />
+        <code>{{ release.tag_name }}</code>
+        · by <a href="/{{ author.username }}">{{ author.username }}</a>
+        · {{ release.created_at }}
+        {% if !tag_target.is_empty() %}
+          · <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ tag_target }}"><code>{{ tag_short }}</code></a>
+        {% endif %}
+        · <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ release.tag_name }}">browse files</a>
+      </p>
+    </div>
+    {% if access.can_write() %}
+    <div class="kg-actions">
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/edit">
+        <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
+        edit
+      </a>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/delete" onsubmit="return confirm('Delete release {{ release.tag_name }}? Assets will be removed. The git tag is kept.')">
+        <button class="kg-btn kg-btn--danger-ghost" type="submit">
+          <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
+          delete
+        </button>
+      </form>
+    </div>
+    {% endif %}
+  </div>
+
+  {% let tab = "releases" %}
+  {% let archive_ref = release.tag_name.clone() %}
+  {% include "partials/repo_tabs.html" %}
+
+  <div class="kg-release-layout">
+    <div class="kg-release-main">
+      <span class="kg-kicker">release notes</span>
+      {% if release.body.is_empty() %}
+        <p class="kg-muted">No release notes.</p>
+      {% else %}
+        <article class="kg-readme kg-release-notes">{{ body_html|safe }}</article>
+      {% endif %}
+    </div>
+
+    <aside class="kg-release-aside">
+      <span class="kg-kicker">assets</span>
+      {% if assets.is_empty() %}
+        <p class="kg-muted">No assets yet.</p>
+      {% else %}
+        <ul class="kg-asset-list">
+          {% for a in assets %}
+          <li>
+            <a href="/{{ owner.username }}/{{ repo.name }}/assets/{{ a.id }}">
+              <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+              {{ a.filename }}
+            </a>
+            <span class="kg-meta">{{ a.size_label }}</span>
+            {% if access.can_write() %}
+            <form method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/assets/{{ a.id }}/delete" onsubmit="return confirm('Delete {{ a.filename }}?')" style="display:inline">
+              <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete asset">
+                <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
+              </button>
+            </form>
+            {% endif %}
+          </li>
+          {% endfor %}
+        </ul>
+      {% endif %}
+
+      <div class="kg-asset-source">
+        <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ release.tag_name }}">
+          <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+          Source code (zip)
+        </a>
+      </div>
+
+      {% if access.can_write() %}
+        <form class="kg-form kg-asset-upload" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/upload" enctype="multipart/form-data">
+          <label>
+            upload asset
+            <input type="file" name="asset" required />
+          </label>
+          <div class="kg-actions">
+            <button class="kg-btn" type="submit">
+              <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span>
+              upload
+            </button>
+          </div>
+        </form>
+      {% endif %}
+    </aside>
+  </div>
+</section>
+{% endblock %}
diff --git a/templates/releases_list.html b/templates/releases_list.html
new file mode 100644
index 0000000..ab3d2d9
--- /dev/null
+++ b/templates/releases_list.html
@@ -0,0 +1,46 @@
+{% extends "layout.html" %}
+
+{% block title %}releases · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · releases
+  </h1>
+  {% let tab = "releases" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  {% if access.can_write() %}
+    <div class="kg-actions">
+      <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/releases/new">
+        <span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span>
+        new release
+      </a>
+    </div>
+  {% endif %}
+
+  {% if releases.is_empty() %}
+    <p class="kg-muted">No releases.</p>
+  {% else %}
+    <ul class="kg-release-list">
+      {% for r in releases %}
+      <li class="kg-release-list__item">
+        <div>
+          <a class="kg-release-list__title" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ r.tag_name }}">
+            {{ r.title }}
+          </a>
+          <p class="kg-meta">
+            <code>{{ r.tag_name }}</code>
+            · {{ r.created_at }}
+            {% if r.is_draft %}<span class="kg-badge">draft</span>{% endif %}
+            {% if r.is_prerelease %}<span class="kg-badge kg-badge--pre">pre-release</span>{% endif %}
+          </p>
+        </div>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_blob.html b/templates/repo_blob.html
new file mode 100644
index 0000000..5b0ff48
--- /dev/null
+++ b/templates/repo_blob.html
@@ -0,0 +1,53 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block head %}
+{% if is_markdown %}
+{% include "partials/katex.html" %}
+{% endif %}
+{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+  </h1>
+  {% let tab = "code" %}
+  {% let archive_ref = branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <p class="kg-meta kg-breadcrumbs">
+    <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a>
+    {% for (name, full) in breadcrumbs %}
+      /
+      {% if loop.last %}
+        {{ name }}
+      {% else %}
+        <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a>
+      {% endif %}
+    {% endfor %}
+    · {{ size }} bytes
+  </p>
+
+  <div class="kg-blobbar">
+    <span class="kg-meta">{{ path }}</span>
+    <div class="kg-blobbar__actions">
+      <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}">
+        <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+        Download raw
+      </a>
+    </div>
+  </div>
+
+  {% if binary %}
+    <p class="kg-muted">Binary file.</p>
+  {% else if let Some(html) = content_html %}
+    {% if is_markdown %}
+      <article class="kg-readme kg-md">{{ html|safe }}</article>
+    {% else %}
+      {{ html|safe }}
+    {% endif %}
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_branches.html b/templates/repo_branches.html
new file mode 100644
index 0000000..5691acd
--- /dev/null
+++ b/templates/repo_branches.html
@@ -0,0 +1,162 @@
+{% extends "layout.html" %}
+
+{% block title %}branches · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · branches &amp; tags
+  </h1>
+  {% let tab = "branches" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+
+  <h2 class="kg-subtitle">Branches</h2>
+  {% if branches.is_empty() %}
+    <p class="kg-muted">No branches.</p>
+  {% else %}
+    <table class="kg-branch-table">
+      <thead>
+        <tr>
+          <th>branch</th>
+          <th>updated</th>
+          <th>vs {{ repo.default_branch }}</th>
+          <th>pull</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for b in branches %}
+        <tr>
+          <td>
+            <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ b.name }}">
+              <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
+              {{ b.name }}
+            </a>
+            {% if b.is_default %}<span class="kg-badge">default</span>{% endif %}
+          </td>
+          <td class="kg-meta">{{ b.updated }}</td>
+          <td class="kg-meta">
+            {% if b.is_default %}
+              —
+            {% else %}
+              {% if b.ahead > 0 %}<span>{{ b.ahead }} ahead</span>{% endif %}
+              {% if b.ahead > 0 && b.behind > 0 %} · {% endif %}
+              {% if b.behind > 0 %}<span>{{ b.behind }} behind</span>{% endif %}
+              {% if b.ahead == 0 && b.behind == 0 %}even{% endif %}
+            {% endif %}
+          </td>
+          <td>
+            {% if let Some(n) = b.pull_number %}
+              <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ n }}">#{{ n }}</a>
+            {% else %}
+              <span class="kg-meta">—</span>
+            {% endif %}
+          </td>
+          <td>
+            {% if access.can_write() && !b.is_default %}
+            <div class="kg-branch-actions">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/rename" style="display:inline-flex;gap:0.35rem">
+                <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
+                <button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
+                  <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
+                </button>
+              </form>
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')">
+                <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
+                  <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
+                </button>
+              </form>
+            </div>
+            {% endif %}
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  {% endif %}
+
+  <h2 class="kg-subtitle" style="margin-top:2.5rem">Tags</h2>
+
+  {% if access.can_write() %}
+  <form class="kg-form kg-tag-create" method="post" action="/{{ owner.username }}/{{ repo.name }}/tags">
+    <label>
+      name
+      <input type="text" name="name" required placeholder="v1.0.0" />
+    </label>
+    <label>
+      target
+      <input type="text" name="target" placeholder="{{ repo.default_branch }}" value="{{ repo.default_branch }}" />
+    </label>
+    <label>
+      message
+      <input type="text" name="message" placeholder="optional annotated message" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">
+        <span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span>
+        create tag
+      </button>
+    </div>
+  </form>
+  {% endif %}
+
+  {% if tags.is_empty() %}
+    <p class="kg-muted">No tags.</p>
+  {% else %}
+    <table class="kg-branch-table">
+      <thead>
+        <tr>
+          <th>tag</th>
+          <th>commit</th>
+          <th>message</th>
+          <th>updated</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for t in tags %}
+        <tr>
+          <td>
+            <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ t.name }}">
+              <span class="kg-icon" style="--icon:url('/static/icons/package.svg')"></span>
+              {{ t.name }}
+            </a>
+            {% if t.has_release %}
+              <a class="kg-badge kg-badge--open" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ t.name }}">release</a>
+            {% endif %}
+          </td>
+          <td>
+            <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ t.target }}"><code>{{ t.short_id }}</code></a>
+          </td>
+          <td class="kg-meta">{{ t.message }}</td>
+          <td class="kg-meta">{{ t.updated }}</td>
+          <td>
+            {% if access.can_write() %}
+            <div class="kg-branch-actions">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/rename" style="display:inline-flex;gap:0.35rem">
+                <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
+                <button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
+                  <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
+                </button>
+              </form>
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')">
+                <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
+                  <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
+                </button>
+              </form>
+            </div>
+            {% endif %}
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_commit.html b/templates/repo_commit.html
new file mode 100644
index 0000000..7551888
--- /dev/null
+++ b/templates/repo_commit.html
@@ -0,0 +1,22 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ commit.short_id }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · commit
+  </h1>
+  {% let tab = "commits" %}
+  {% let archive_ref = commit.id %}
+  {% include "partials/repo_tabs.html" %}
+
+  <p><code>{{ commit.id }}</code></p>
+  <p class="kg-title">{{ message_html|safe }}</p>
+  <p class="kg-meta">{{ commit.author }} &lt;{{ commit.email }}&gt; · {{ commit.time }}</p>
+
+  <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p>
+  {{ diff_html|safe }}
+</section>
+{% endblock %}
diff --git a/templates/repo_commits.html b/templates/repo_commits.html
new file mode 100644
index 0000000..9b472cb
--- /dev/null
+++ b/templates/repo_commits.html
@@ -0,0 +1,42 @@
+{% extends "layout.html" %}
+
+{% block title %}commits · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · commits
+  </h1>
+  {% let tab = "commits" %}
+  {% let archive_ref = branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <p class="kg-meta">
+    <a href="/{{ owner.username }}/{{ repo.name }}/branches">
+      <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
+      {{ branch }}
+    </a>
+  </p>
+
+  {% if commits.is_empty() %}
+    <p class="kg-muted">No commits.</p>
+  {% else %}
+    <ul class="kg-list">
+      {% for c in commits %}
+      <li>
+        <span>
+          <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits">
+            <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
+            <code>{{ c.short_id }}</code>
+          </a>
+          <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
+          <span class="kg-muted"> — {{ c.author }}</span>
+        </span>
+        <span class="kg-meta">{{ c.time }}</span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_diff.html b/templates/repo_diff.html
new file mode 100644
index 0000000..129dab6
--- /dev/null
+++ b/templates/repo_diff.html
@@ -0,0 +1,28 @@
+{% extends "layout.html" %}
+
+{% block title %}diff {{ commit.short_id }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · diff
+  </h1>
+  {% let tab = "commits" %}
+  {% let archive_ref = commit.id %}
+  {% include "partials/repo_tabs.html" %}
+
+  <p class="kg-meta">
+    <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits">
+      <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
+      {{ commit.short_id }}
+    </a>
+    · {{ commit.author }}
+  </p>
+  <p class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}">{{ commit.message }}</a>
+  </p>
+
+  {{ diff_html|safe }}
+</section>
+{% endblock %}
diff --git a/templates/repo_home.html b/templates/repo_home.html
new file mode 100644
index 0000000..dc12607
--- /dev/null
+++ b/templates/repo_home.html
@@ -0,0 +1,124 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block head %}
+{% if readme_html.is_some() %}
+{% include "partials/katex.html" %}
+{% endif %}
+{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <div class="kg-profile-head">
+    <img class="kg-avatar" src="{{ owner_avatar }}" alt="" />
+    <div class="kg-profile-head__main">
+      <h1 class="kg-title">
+        <a href="/{{ owner.username }}">{{ owner.username }}</a>
+        /
+        <strong>{{ repo.name }}</strong>
+        {% if repo.visibility == "private" %}<span class="kg-badge">private</span>{% endif %}
+        {% if repo.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %}
+        {% if forked_from.is_some() %}<span class="kg-badge">fork</span>{% endif %}
+      </h1>
+      {% if let Some((fo, fn_)) = forked_from %}
+        <p class="kg-meta">forked from <a href="/{{ fo }}/{{ fn_ }}">{{ fo }}/{{ fn_ }}</a></p>
+      {% endif %}
+      {% if !repo.description.is_empty() %}
+        <p class="kg-muted">{{ repo.description }}</p>
+      {% endif %}
+    </div>
+    <div class="kg-social">
+      {% if viewer.is_some() %}
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/watch">
+        <button class="kg-btn kg-btn--ghost" type="submit">
+          <span class="kg-icon" style="--icon:url('/static/icons/eye.svg')"></span>
+          {% if watching %}unwatch{% else %}watch{% endif %}
+          <span class="kg-meta">{{ repo.watches_count }}</span>
+        </button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/fork">
+        <button class="kg-btn kg-btn--ghost" type="submit">
+          <span class="kg-icon" style="--icon:url('/static/icons/git-fork.svg')"></span>
+          fork
+          <span class="kg-meta">{{ repo.forks_count }}</span>
+        </button>
+      </form>
+      <form method="post" action="/{{ owner.username }}/{{ repo.name }}/star">
+        <button class="kg-btn kg-btn--ghost" type="submit">
+          <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span>
+          {% if starred %}unstar{% else %}star{% endif %}
+          <span class="kg-meta">{{ repo.stars_count }}</span>
+        </button>
+      </form>
+      {% else %}
+        <span class="kg-meta">
+          <span class="kg-icon" style="--icon:url('/static/icons/eye.svg')"></span> {{ repo.watches_count }}
+          ·
+          <span class="kg-icon" style="--icon:url('/static/icons/git-fork.svg')"></span> {{ repo.forks_count }}
+          ·
+          <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span> {{ repo.stars_count }}
+        </span>
+      {% endif %}
+    </div>
+  </div>
+
+  {% let tab = "code" %}
+  {% let archive_ref = current_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  {% if empty %}
+    <p class="kg-muted">Empty repository. Push to get started.</p>
+    <div class="kg-clone-empty">
+      <code class="kg-clone">git clone {{ clone_http }}</code>
+      <code class="kg-clone">git clone {{ clone_ssh }}</code>
+    </div>
+  {% else %}
+    {% let branch = current_branch %}
+    {% let upload_path = "" %}
+    {% include "partials/branch_bar.html" %}
+
+    {% if !languages.is_empty() %}
+      <div class="kg-langbar" role="img" aria-label="Languages">
+        {% for lang in languages %}
+          <span style="width: {{ lang.percent }}%; background: {{ lang.color }}" title="{{ lang.name }} {{ lang.percent }}%"></span>
+        {% endfor %}
+      </div>
+      <ul class="kg-langlist">
+        {% for lang in languages %}
+          <li>
+            <span class="kg-langdot" style="background: {{ lang.color }}" aria-hidden="true"></span>
+            {{ lang.name }} <span class="kg-meta">{{ lang.percent }}%</span>
+          </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+
+    <ul class="kg-tree">
+      {% for e in entries %}
+      <li>
+        {% if e.is_dir %}
+          <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
+          <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
+          <span class="kg-meta">{{ e.mode }}</span>
+          <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ current_branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
+            <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+            <span class="kg-sr-only">Download</span>
+          </a>
+        {% else %}
+          <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
+          <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
+          <span class="kg-meta">{{ e.mode }}</span>
+        {% endif %}
+      </li>
+      {% endfor %}
+    </ul>
+
+    {% if let Some(html) = readme_html %}
+      <article class="kg-readme">
+        {{ html|safe }}
+      </article>
+    {% endif %}
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
new file mode 100644
index 0000000..effe71d
--- /dev/null
+++ b/templates/repo_settings.html
@@ -0,0 +1,224 @@
+{% extends "layout.html" %}
+
+{% block title %}settings · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+    · settings
+  </h1>
+  {% let tab = "settings" %}
+  {% let archive_ref = repo.default_branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+
+  <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings" style="max-width:none">
+    <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0">
+      <span class="kg-kicker">general</span>
+      <label>
+        description
+        <textarea name="description" rows="3">{{ repo.description }}</textarea>
+      </label>
+      <label>
+        visibility
+        <select name="visibility">
+          <option value="public"{% if repo.visibility == "public" %} selected{% endif %}>public</option>
+          <option value="private"{% if repo.visibility == "private" %} selected{% endif %}>private</option>
+        </select>
+      </label>
+      <label>
+        default branch
+        {% if branches.is_empty() %}
+          <input type="text" name="default_branch" value="{{ repo.default_branch }}" required />
+        {% else %}
+          <select name="default_branch">
+            {% for b in branches %}
+              <option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option>
+            {% endfor %}
+          </select>
+        {% endif %}
+      </label>
+    </div>
+
+    <div class="kg-settings-section">
+      <span class="kg-kicker">features</span>
+      <label class="row">
+        <input type="checkbox" name="issues_enabled"{% if repo.issues_enabled %} checked{% endif %} />
+        issues enabled
+      </label>
+      <label class="row">
+        <input type="checkbox" name="pulls_enabled"{% if repo.pulls_enabled %} checked{% endif %} />
+        pulls enabled
+      </label>
+      <label class="row">
+        <input type="checkbox" name="releases_enabled"{% if repo.releases_enabled %} checked{% endif %} />
+        releases enabled
+      </label>
+    </div>
+
+    <div class="kg-settings-section">
+      <span class="kg-kicker">pull requests</span>
+      <label class="row">
+        <input type="checkbox" name="allow_merge"{% if repo.allow_merge %} checked{% endif %} />
+        allow merge
+      </label>
+      <label class="row">
+        <input type="checkbox" name="allow_squash"{% if repo.allow_squash %} checked{% endif %} />
+        allow squash
+      </label>
+      <label class="row">
+        <input type="checkbox" name="allow_rebase"{% if repo.allow_rebase %} checked{% endif %} />
+        allow rebase
+      </label>
+      <label>
+        default merge style
+        <select name="default_merge_style">
+          <option value="merge"{% if repo.default_merge_style == "merge" %} selected{% endif %}>merge</option>
+          <option value="squash"{% if repo.default_merge_style == "squash" %} selected{% endif %}>squash</option>
+          <option value="rebase"{% if repo.default_merge_style == "rebase" %} selected{% endif %}>rebase</option>
+        </select>
+      </label>
+    </div>
+
+    <div class="kg-settings-section">
+      <span class="kg-kicker">branch protection</span>
+      <label class="row">
+        <input type="checkbox" name="protect_default_branch"{% if repo.protect_default_branch %} checked{% endif %} />
+        protect default branch
+      </label>
+      <label class="row">
+        <input type="checkbox" name="protect_block_force_push"{% if repo.protect_block_force_push %} checked{% endif %} />
+        block force push
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">save settings</button>
+      </div>
+    </div>
+  </form>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">branch rules</span>
+    <p class="kg-muted">Pattern-based rules (e.g. <code>main</code>, <code>release/*</code>).</p>
+    {% if branch_rules.is_empty() %}
+      <p class="kg-muted">No branch rules yet.</p>
+    {% else %}
+      <ul class="kg-list">
+        {% for rule in branch_rules %}
+        <li>
+          <span>
+            <code>{{ rule.pattern }}</code>
+            {% if rule.require_pr %}<span class="kg-badge">require PR</span>{% endif %}
+            {% if rule.block_force_push %}<span class="kg-badge">no force</span>{% endif %}
+            {% if rule.allow_deletions %}<span class="kg-badge">deletions ok</span>{% endif %}
+          </span>
+          <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/branch-rules/{{ rule.id }}/delete">
+            <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
+          </form>
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/branch-rules" style="margin-top:1rem">
+      <label>
+        pattern
+        <input type="text" name="pattern" required placeholder="main" />
+      </label>
+      <label class="row">
+        <input type="checkbox" name="require_pr" />
+        require pull request
+      </label>
+      <label class="row">
+        <input type="checkbox" name="block_force_push" checked />
+        block force push
+      </label>
+      <label class="row">
+        <input type="checkbox" name="allow_deletions" />
+        allow deletions
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">add rule</button>
+      </div>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
+    <span class="kg-kicker">collaborators</span>
+    {% if collaborators.is_empty() %}
+      <p class="kg-muted">No collaborators.</p>
+    {% else %}
+      <ul class="kg-list">
+        {% for c in collaborators %}
+        <li>
+          <span>
+            <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" />
+            <a href="/{{ c.user.username }}">{{ c.user.username }}</a>
+            <span class="kg-badge">{{ c.role }}</span>
+          </span>
+          <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/collaborators/{{ c.user.id }}/remove">
+            <button class="kg-btn kg-btn--ghost" type="submit">remove</button>
+          </form>
+        </li>
+        {% endfor %}
+      </ul>
+    {% endif %}
+
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/collaborators" style="margin-top:1rem">
+      <label>
+        username
+        <input type="text" name="username" required />
+      </label>
+      <label>
+        role
+        <select name="role">
+          <option value="read">read</option>
+          <option value="write" selected>write</option>
+          <option value="admin">admin</option>
+        </select>
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">add collaborator</button>
+      </div>
+    </form>
+  </div>
+
+  {% if access.can_owner() || is_site_admin %}
+    <div class="kg-danger-zone">
+      <span class="kg-kicker">danger zone</span>
+
+      {% if access.can_owner() %}
+      <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/archive">
+        <input type="hidden" name="archived" value="{% if repo.archived %}unarchive{% else %}archive{% endif %}" />
+        <div class="kg-danger-row">
+          <p class="kg-muted" style="margin:0;flex:1">{% if repo.archived %}Unarchive this repository.{% else %}Archive — read-only for everyone.{% endif %}</p>
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">{% if repo.archived %}unarchive{% else %}archive{% endif %}</button>
+        </div>
+      </form>
+
+      <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/transfer">
+        <div class="kg-danger-row">
+          <label>
+            transfer to username
+            <input type="text" name="new_owner" required />
+          </label>
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">transfer</button>
+        </div>
+      </form>
+      {% endif %}
+
+      <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/delete">
+        <div class="kg-danger-row">
+          <label>
+            type <code>{{ repo.name }}</code> to delete
+            <input type="text" name="confirm" required autocomplete="off" />
+          </label>
+          <button class="kg-btn kg-btn--danger" type="submit">delete repository</button>
+        </div>
+      </form>
+    </div>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/repo_tree.html b/templates/repo_tree.html
new file mode 100644
index 0000000..9a27884
--- /dev/null
+++ b/templates/repo_tree.html
@@ -0,0 +1,50 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <h1 class="kg-title">
+    <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+  </h1>
+  {% let tab = "code" %}
+  {% let archive_ref = branch %}
+  {% include "partials/repo_tabs.html" %}
+
+  <p class="kg-meta kg-breadcrumbs">
+    <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a>
+    {% for (name, full) in breadcrumbs %}
+      / <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a>
+    {% endfor %}
+  </p>
+
+  {% let upload_path = path %}
+  {% include "partials/branch_bar.html" %}
+
+  <ul class="kg-tree">
+    {% for e in entries %}
+    <li>
+      {% if e.is_dir %}
+        <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
+        <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
+        <span class="kg-meta">{{ e.mode }}</span>
+        <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
+          <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
+          <span class="kg-sr-only">Download</span>
+        </a>
+      {% else %}
+        <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
+        <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
+        <span class="kg-meta">{{ e.mode }}</span>
+      {% endif %}
+    </li>
+    {% endfor %}
+  </ul>
+
+  {% if let Some(html) = readme_html %}
+    <article class="kg-readme">
+      {{ html|safe }}
+    </article>
+  {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/signup.html b/templates/signup.html
new file mode 100644
index 0000000..ff9d351
--- /dev/null
+++ b/templates/signup.html
@@ -0,0 +1,40 @@
+{% extends "layout.html" %}
+
+{% block title %}sign up — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section kg-auth">
+  <p class="kg-landing__brand">
+    <span class="kg-mark__glyph" aria-hidden="true">狐</span>
+    <span class="kg-mark__name">kitgit</span>
+  </p>
+  <h1 class="kg-title">sign up</h1>
+  {% if let Some(err) = error %}
+    <div class="kg-flash">{{ err }}</div>
+  {% endif %}
+  <form class="kg-form" method="post" action="/auth/signup" autocomplete="on">
+    <label>
+      username
+      <input type="text" name="username" required autofocus autocomplete="username" pattern="[A-Za-z0-9._\-]+" minlength="2" />
+    </label>
+    <label>
+      display name
+      <input type="text" name="display_name" autocomplete="name" />
+    </label>
+    <label>
+      email
+      <input type="email" name="email" required autocomplete="email" />
+    </label>
+    <label>
+      password
+      <input type="password" name="password" required minlength="8" autocomplete="new-password" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">create account</button>
+    </div>
+  </form>
+  <p class="kg-meta" style="margin-top:1.25rem">
+    Already have an account? <a href="/auth/login">Log in</a>
+  </p>
+</section>
+{% endblock %}