Преглед изворни кода

Initial import of gitr rewrite

LoganZ2 пре 8 часа
комит
904ea0be61
20 измењених фајлова са 8634 додато и 0 уклоњено
  1. 1 0
      .gitignore
  2. 2021 0
      Cargo.lock
  3. 20 0
      Cargo.toml
  4. 35 0
      MODULE_MAPPING.md
  5. 415 0
      REWRITE_PROGRESS.md
  6. 1 0
      frontend-reference
  7. 14 0
      gitr.example.toml
  8. 1 0
      gogs-reference
  9. 12 0
      src/app.rs
  10. 158 0
      src/conf.rs
  11. 1068 0
      src/db.rs
  12. 87 0
      src/error.rs
  13. 630 0
      src/git.rs
  14. 647 0
      src/http.rs
  15. 9 0
      src/lib.rs
  16. 24 0
      src/main.rs
  17. 396 0
      src/models.rs
  18. 9 0
      src/repox.rs
  19. 1163 0
      src/service.rs
  20. 1923 0
      tests/core_flow.rs

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+/target

+ 2021 - 0
Cargo.lock

@@ -0,0 +1,2021 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "actix-codec"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "actix-http"
+version = "3.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662"
+dependencies = [
+ "actix-codec",
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "base64",
+ "bitflags",
+ "brotli",
+ "bytes",
+ "bytestring",
+ "derive_more",
+ "encoding_rs",
+ "flate2",
+ "foldhash",
+ "futures-core",
+ "h2",
+ "http",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "language-tags",
+ "local-channel",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rand",
+ "sha1",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "zstd",
+]
+
+[[package]]
+name = "actix-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "actix-router"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
+dependencies = [
+ "bytestring",
+ "cfg-if",
+ "http",
+ "regex",
+ "regex-lite",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "actix-rt"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
+dependencies = [
+ "futures-core",
+ "tokio",
+]
+
+[[package]]
+name = "actix-server"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
+dependencies = [
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "futures-core",
+ "futures-util",
+ "mio",
+ "socket2 0.5.10",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "actix-service"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-utils"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
+dependencies = [
+ "local-waker",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-web"
+version = "4.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf"
+dependencies = [
+ "actix-codec",
+ "actix-http",
+ "actix-macros",
+ "actix-router",
+ "actix-rt",
+ "actix-server",
+ "actix-service",
+ "actix-utils",
+ "actix-web-codegen",
+ "bytes",
+ "bytestring",
+ "cfg-if",
+ "cookie",
+ "derive_more",
+ "encoding_rs",
+ "foldhash",
+ "futures-core",
+ "futures-util",
+ "impl-more",
+ "itoa",
+ "language-tags",
+ "log",
+ "mime",
+ "once_cell",
+ "pin-project-lite",
+ "regex",
+ "regex-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "smallvec",
+ "socket2 0.6.3",
+ "time",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "actix-web-codegen"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8"
+dependencies = [
+ "actix-router",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[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 = "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 = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[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-buffer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "bytestring"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
+dependencies = [
+ "bytes",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
+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 = "chacha20"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cookie"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[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 = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+ "unicode-xid",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
+dependencies = [
+ "block-buffer 0.12.0",
+ "const-oid",
+ "crypto-common 0.2.1",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[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 = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[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 = "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-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gitr"
+version = "0.1.0"
+dependencies = [
+ "actix-http",
+ "actix-web",
+ "argon2",
+ "hex",
+ "rand_core 0.6.4",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror",
+ "tokio",
+ "toml",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[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 = "hybrid-array"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "impl-more"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "language-tags"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "local-channel"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "local-waker",
+]
+
+[[package]]
+name = "local-waker"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
+
+[[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.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[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.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[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",
+ "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 = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.2",
+ "rand_core 0.10.1",
+]
+
+[[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 = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.2",
+]
+
+[[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 0.10.7",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[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 = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+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 = "tokio"
+version = "1.52.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.3",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "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",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "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 = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]

+ 20 - 0
Cargo.toml

@@ -0,0 +1,20 @@
+[package]
+name = "gitr"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+argon2 = "0.5"
+actix-web = "4"
+hex = "0.4"
+rand_core = { version = "0.6", features = ["getrandom"] }
+rusqlite = { version = "0.32", features = ["bundled"] }
+serde = { version = "1", features = ["derive"] }
+sha2 = "0.10"
+thiserror = "2"
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
+toml = "0.8"
+
+[dev-dependencies]
+actix-http = "3"
+serde_json = "1"

+ 35 - 0
MODULE_MAPPING.md

@@ -0,0 +1,35 @@
+# Gitr Phase 1 Mapping
+
+This repository currently reimplements the first core Gogs slice instead of the full product.
+
+Gogs reference modules:
+
+- `internal/conf/conf.go`
+  Rust: `src/conf.rs`
+- `internal/repox/repox.go`
+  Rust: `src/repox.rs`
+- `internal/database/users.go`
+  Rust: `src/db.rs`, `src/service.rs`
+- `internal/database/repositories.go`
+  Rust: `src/db.rs`, `src/service.rs`
+- `internal/database/repo.go` repository initialization path
+  Rust: `src/git.rs`, `src/service.rs`
+- `cmd/gogs/web.go` minimal API surface only
+  Rust: `src/http.rs`
+
+Implemented in this phase:
+
+- Config loading from `gitr.toml` or defaults
+- SQLite-backed user metadata
+- SQLite-backed repository metadata
+- Repository path derivation by owner/name
+- Bare Git repository initialization
+- Optional auto-init with a first commit
+- Minimal JSON APIs for user and repository creation/query
+
+Not implemented yet:
+
+- Session/authentication flows
+- SSH serving and hooks
+- Issues, pulls, wiki, mirrors, actions, permissions
+- HTML rendering and frontend routes

+ 415 - 0
REWRITE_PROGRESS.md

@@ -0,0 +1,415 @@
+# Gitr 重写计划
+
+## 目标
+
+这个项目是对 Gogs 核心后端的 Rust 重写,当前约束有三个:
+
+- 尽量按 Gogs 原有模块边界重写,而不是只做概念相似
+- 尽量保持运行时和部署成本轻量
+- 尽量用确定性的端到端测试验证行为正确
+
+当前技术选型:
+
+- HTTP: `actix-web`
+- 数据库: `SQLite` + `rusqlite`
+- Git 交互: 外部 `git` 二进制
+
+当前不包含:
+
+- `frontend`
+- HTML 模板与浏览器页面
+- session/cookie 登录流
+
+## 当前定位
+
+当前代码已经不是“脚手架阶段”,而是已经完成了第一批核心后端闭环:
+
+- 用户与本地密码认证
+- access token 鉴权
+- 仓库创建与 bare Git 初始化
+- Git over HTTP 最小读写链路
+- 协作者与最小权限模型
+- fork
+- 分支列表
+- 最小 PR/MR 创建、列表、详情、compare、merge、close、reopen
+
+这意味着项目现在已经具备一个“最小可用的 Git 服务后端核心”,但距离 Gogs 的完整产品能力还差几个大块。
+
+## 已完成功能
+
+### 1. 配置与基础设施
+
+对应模块:
+
+- [`src/conf.rs`](./src/conf.rs)
+- [`src/repox.rs`](./src/repox.rs)
+
+已完成:
+
+- 从 `gitr.toml` 或默认值加载配置
+- 自动准备数据库目录和仓库根目录
+- 统一推导 `owner/repo.git` 的磁盘路径
+
+### 2. 用户、认证与访问令牌
+
+对应模块:
+
+- [`src/db.rs`](./src/db.rs)
+- [`src/service.rs`](./src/service.rs)
+- [`src/http.rs`](./src/http.rs)
+
+已完成:
+
+- 创建本地用户
+- 用户名、邮箱唯一性校验
+- 用户名合法性校验
+- 使用 `argon2` 存储密码哈希
+- 支持用户名或邮箱登录
+- 登录后签发 access token
+- 支持额外创建命名 token
+- 支持列出当前用户 token
+- 支持删除当前用户 token
+- token 名在同一用户下唯一
+- 数据库只存 token 的 SHA-256 hash
+- Bearer token 鉴权
+- token 被使用后更新 `updated_unix`
+
+当前安全边界:
+
+- `POST /api/admin/users` 只允许首个用户 bootstrap
+- bootstrap 之后只能由 admin 创建新用户
+- API 不再暴露 `password_hash`
+- 匿名读取用户资料时隐藏邮箱
+
+### 3. 仓库核心
+
+对应模块:
+
+- [`src/db.rs`](./src/db.rs)
+- [`src/service.rs`](./src/service.rs)
+- [`src/git.rs`](./src/git.rs)
+
+已完成:
+
+- 创建仓库元数据
+- 同 owner 下仓库名唯一
+- 仓库名合法性校验
+- 初始化 bare Git 仓库
+- 设置默认分支
+- 运行 `update-server-info`
+- 支持 `auto_init`
+- `auto_init` 时创建首个 README 提交
+- Git 初始化失败时回滚数据库记录和仓库目录
+
+### 4. 权限与协作
+
+对应模块:
+
+- [`src/db.rs`](./src/db.rs)
+- [`src/models.rs`](./src/models.rs)
+- [`src/service.rs`](./src/service.rs)
+
+已完成:
+
+- `access` 表
+- `collaboration` 表
+- 最小权限枚举:`Read / Write / Admin / Owner`
+- owner 隐式拥有 `Owner`
+- 公有仓库匿名只读
+- 协作者添加、更新、删除
+- 协作者列表
+- 协作者单项检查
+- collaborator permission 严格校验,只接受 `read / write / admin`
+- Git HTTP 和 API 复用同一套权限判断
+
+当前语义:
+
+- 私有仓库未授权访问尽量返回 `404`
+- 公有仓库上的权限不足返回 `403`
+
+### 5. Git over HTTP
+
+对应模块:
+
+- [`src/http.rs`](./src/http.rs)
+- [`src/git.rs`](./src/git.rs)
+- [`src/service.rs`](./src/service.rs)
+
+已完成:
+
+- 通过 `git http-backend` 提供最小 Git HTTP 能力
+- 公有仓库匿名 `git-upload-pack`
+- 私有仓库 Basic 认证
+- Basic 认证支持密码或 access token
+- 按读写权限区分 `upload-pack` / `receive-pack`
+
+### 6. Fork
+
+对应模块:
+
+- [`src/db.rs`](./src/db.rs)
+- [`src/service.rs`](./src/service.rs)
+- [`src/git.rs`](./src/git.rs)
+
+已完成:
+
+- fork 元数据:`is_fork`、`fork_id`
+- 禁止 fork 到原 owner 自己
+- 禁止同一用户重复 fork 同一仓库
+- 通过 bare clone 复制源仓库
+
+### 7. 分支
+
+对应模块:
+
+- [`src/git.rs`](./src/git.rs)
+- [`src/service.rs`](./src/service.rs)
+
+已完成:
+
+- 列出分支
+- 校验分支存在性
+
+### 8. 最小 PR / MR 主链
+
+说明:
+
+- 当前明确不接 Gogs 的 issue/comment/timeline 体系
+- 只保留代码合并请求本身
+
+对应模块:
+
+- [`src/models.rs`](./src/models.rs)
+- [`src/db.rs`](./src/db.rs)
+- [`src/service.rs`](./src/service.rs)
+- [`src/http.rs`](./src/http.rs)
+
+已完成:
+
+- `pull_request` 表
+- 创建 PR/MR
+- 列出 PR/MR
+- 获取 PR/MR 详情
+- compare
+- merge
+- close
+- reopen
+- 校验 base branch / head branch 存在
+- 校验 head repo 必须为同仓库或其 fork
+- 校验创建者对 base repo 有读权限
+- 校验创建者对 head repo 有写权限
+- 阻止同一 head/base 分支对的未合并重复 PR
+- 计算 merge base
+- 通过临时克隆试合并,判断 `Mergeable / Conflict`
+- merge 后记录 `merged_commit_id`
+- merged PR 详情不会再把 base-only 的后续提交混入 compare
+- PR index 在数据库事务内分配,并有唯一索引保护
+
+### 9. 已暴露的 API
+
+对应模块:
+
+- [`src/http.rs`](./src/http.rs)
+- [`src/main.rs`](./src/main.rs)
+
+当前已有接口:
+
+- `POST /api/admin/users`
+- `POST /api/user/login`
+- `POST /api/user/tokens`
+- `GET /api/user/tokens`
+- `DELETE /api/user/tokens/{id}`
+- `GET /api/users/{username}`
+- `GET /api/users/{username}/repos`
+- `GET /api/user/repos`
+- `POST /api/repos`
+- `GET /api/repos/search`
+- `GET /api/repos/{owner}/{repo}`
+- `POST /api/repos/{owner}/{repo}/forks`
+- `GET /api/repos/{owner}/{repo}/branches`
+- `GET /api/repos/{owner}/{repo}/compare`
+- `POST /api/repos/{owner}/{repo}/pulls`
+- `GET /api/repos/{owner}/{repo}/pulls`
+- `GET /api/repos/{owner}/{repo}/pulls/{index}`
+- `POST /api/repos/{owner}/{repo}/pulls/{index}/merge`
+- `POST /api/repos/{owner}/{repo}/pulls/{index}/close`
+- `POST /api/repos/{owner}/{repo}/pulls/{index}/reopen`
+- `POST /api/repos/{owner}/{repo}/collaborators`
+- `GET /api/repos/{owner}/{repo}/collaborators`
+- `GET /api/repos/{owner}/{repo}/collaborators/{username}`
+- `DELETE /api/repos/{owner}/{repo}/collaborators/{username}`
+- `GET /healthz`
+
+### 10. Repo 可见性与列表 API
+
+对应模块:
+
+- [`src/db.rs`](./src/db.rs)
+- [`src/service.rs`](./src/service.rs)
+- [`src/http.rs`](./src/http.rs)
+- [`src/models.rs`](./src/models.rs)
+
+已完成:
+
+- owner 维度 repo 列表接口
+- 当前用户可见 repo 集合接口
+- 基于可见性过滤的 repo 搜索接口
+- 统一 repo 可见性过滤函数
+- repo permission DTO
+- repo 列表/详情返回当前请求视角下的 permission 信息
+
+### 11. API 错误语义与返回结构
+
+对应模块:
+
+- [`src/error.rs`](./src/error.rs)
+- [`tests/core_flow.rs`](./tests/core_flow.rs)
+
+已完成:
+
+- API 错误响应统一为结构化 JSON
+- 错误响应包含稳定的 `code` / `message` / `status`
+- `400/401/403/404/409/500` 具备稳定错误码语义
+- `500` 响应不再把底层 I/O / DB / Git 细节直接暴露给客户端
+
+## 已验证行为
+
+主要测试文件:
+
+- [`tests/core_flow.rs`](./tests/core_flow.rs)
+
+目前已覆盖的行为包括:
+
+- 用户创建、重复拒绝、非法用户名拒绝
+- bootstrap admin 与后续 admin-only 创建用户
+- 密码登录、错误密码失败
+- token 创建、唯一性、list/delete、使用后更新时间
+- 仓库创建、自动初始化、重复拒绝、非法仓库名拒绝
+- Git 初始化失败回滚
+- 公有仓库匿名 Git HTTP pull
+- 私有仓库 Git HTTP 鉴权
+- 协作者读权限与禁止 push
+- fork
+- 分支列表
+- PR/MR 创建、去重、列表、详情、compare
+- PR/MR merge、close、reopen
+- reopen 时 duplicate open PR 拒绝
+- merged PR compare 结果稳定
+- 私有仓库元数据与读接口的未授权不可见
+- API 响应不暴露 `password_hash`
+- owner repo list 会隐藏无权限 private repo
+- 当前用户 repo list 会包含 owned/public/collaborator 可见仓库
+- repo search 会按当前可见性过滤结果
+- collaborator list/check 返回协作者及权限
+- private repo 的 collaborator 读接口对未授权用户继续返回 `404`
+- validation / unauthorized / conflict / not-found 错误返回结构化 JSON
+- internal error 返回 `internal_error`,且 message 已脱敏
+
+当前测试状态:
+
+- `cargo test`
+- 现状:`38 passed`
+
+## 还需要完成的功能
+
+下面按优先级拆分,而不是按“想到什么做什么”。
+
+### Phase 1A: 补齐当前这一批核心 API
+
+目标:
+
+- 把“已经有底层能力但 API 还不完整”的部分补齐
+
+还缺:
+
+- token 使用审计字段进一步丰富
+
+建议优先级:
+
+1. token 使用审计字段进一步丰富
+2. 组织/团队出现前的权限边界再抽象
+3. organization / team 模型
+
+### Phase 1B: 强化权限与仓库可见性模型
+
+目标:
+
+- 让“谁能看到什么 repo、谁能对什么 repo 发 PR/fork/compare”更接近 Gogs
+
+还缺:
+
+- 更完整的 public/private/not-found 语义梳理
+- 组织/团队出现前的权限边界再抽象
+- token 使用审计字段进一步丰富
+
+### Phase 2: 组织、团队、组织仓库
+
+这是后续真正会改变权限模型的一大块。
+
+还缺:
+
+- organization 用户模型
+- team
+- team repo permission
+- org 仓库创建
+- org 成员与 owner/admin 关系
+
+这部分完成后,repo 权限才会开始接近 Gogs 的真实结构。
+
+### Phase 3: SSH 与 hook
+
+还缺:
+
+- SSH push/pull 链路
+- server-side hook
+- deploy key
+- 受保护分支最小实现
+
+这部分是“能不能真正拿来替代现有 Git 服务”的关键。
+
+### Phase 4: Issue / Comment / Timeline 体系
+
+当前 PR/MR 是脱离 issue 系统的最小闭环。
+
+如果要继续接近 Gogs,还缺:
+
+- issue
+- issue index
+- comment
+- close/reopen timeline
+- label/milestone
+- PR 与 issue 的完整关联
+
+这块工作量会明显高于当前阶段。
+
+### Phase 5: Web 产品层
+
+当前明确未做:
+
+- HTML/template
+- session 登录流
+- 浏览器交互页面
+- frontend 整合
+
+这块应在后端主链稳定后再接。
+
+## 建议开发顺序
+
+如果继续推进,建议顺序如下:
+
+1. 补 token 使用审计字段
+2. 再抽象一层 organization / team 之前的权限边界
+3. 开始组织/团队模型
+4. 再进入 SSH/hook
+5. 最后再考虑 issue/timeline 和 Web 层
+
+## 当前结论
+
+当前仓库已经完成:
+
+- “单用户/协作者/私有仓库/fork/最小 PR”的第一批核心后端闭环
+
+当前最值得继续做的,不是再加零散功能,而是:
+
+- 先把 token 审计与权限边界抽象补扎实
+- 再进入 organization / team 这条真正影响权限模型的主线

+ 1 - 0
frontend-reference

@@ -0,0 +1 @@
+Subproject commit eb5541527a2e14684e94eb44f1ecc1581392c77e

+ 14 - 0
gitr.example.toml

@@ -0,0 +1,14 @@
+[server]
+bind = "127.0.0.1:3000"
+external_url = "http://127.0.0.1:3000/"
+
+[database]
+path = "./data/gitr.db"
+
+[repository]
+root = "./data/repositories"
+default_branch = "main"
+git_binary = "git"
+
+[app]
+run_user = "git"

+ 1 - 0
gogs-reference

@@ -0,0 +1 @@
+Subproject commit 7297aee50d4c115836c7de8a3a233daaef87b911

+ 12 - 0
src/app.rs

@@ -0,0 +1,12 @@
+use crate::{conf::AppConfig, db::Database};
+
+pub struct AppState {
+    pub config: AppConfig,
+    pub db: Database,
+}
+
+impl AppState {
+    pub fn new(config: AppConfig, db: Database) -> Self {
+        Self { config, db }
+    }
+}

+ 158 - 0
src/conf.rs

@@ -0,0 +1,158 @@
+use std::{
+    env, fs,
+    path::{Path, PathBuf},
+};
+
+use serde::Deserialize;
+
+use crate::error::{AppError, AppResult};
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct AppConfig {
+    #[serde(default)]
+    pub server: ServerConfig,
+    #[serde(default)]
+    pub database: DatabaseConfig,
+    #[serde(default)]
+    pub repository: RepositoryConfig,
+    #[serde(default)]
+    pub app: CoreAppConfig,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ServerConfig {
+    #[serde(default = "default_bind")]
+    pub bind: String,
+    #[serde(default = "default_external_url")]
+    pub external_url: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct DatabaseConfig {
+    #[serde(default = "default_db_path")]
+    pub path: PathBuf,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct RepositoryConfig {
+    #[serde(default = "default_repo_root")]
+    pub root: PathBuf,
+    #[serde(default = "default_branch")]
+    pub default_branch: String,
+    #[serde(default = "default_git_binary")]
+    pub git_binary: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CoreAppConfig {
+    #[serde(default = "default_run_user")]
+    pub run_user: String,
+}
+
+impl Default for AppConfig {
+    fn default() -> Self {
+        Self {
+            server: ServerConfig::default(),
+            database: DatabaseConfig::default(),
+            repository: RepositoryConfig::default(),
+            app: CoreAppConfig::default(),
+        }
+    }
+}
+
+impl Default for ServerConfig {
+    fn default() -> Self {
+        Self {
+            bind: default_bind(),
+            external_url: default_external_url(),
+        }
+    }
+}
+
+impl Default for DatabaseConfig {
+    fn default() -> Self {
+        Self {
+            path: default_db_path(),
+        }
+    }
+}
+
+impl Default for RepositoryConfig {
+    fn default() -> Self {
+        Self {
+            root: default_repo_root(),
+            default_branch: default_branch(),
+            git_binary: default_git_binary(),
+        }
+    }
+}
+
+impl Default for CoreAppConfig {
+    fn default() -> Self {
+        Self {
+            run_user: default_run_user(),
+        }
+    }
+}
+
+impl AppConfig {
+    pub fn load() -> AppResult<Self> {
+        let path = env::var_os("GITR_CONFIG")
+            .map(PathBuf::from)
+            .unwrap_or_else(|| PathBuf::from("gitr.toml"));
+
+        if !path.exists() {
+            return Ok(Self::default());
+        }
+
+        let raw = fs::read_to_string(&path)?;
+        let mut config: AppConfig = toml::from_str(&raw).map_err(AppError::Config)?;
+        config.absolutize_from(path.parent().unwrap_or(Path::new(".")));
+        Ok(config)
+    }
+
+    pub fn prepare(&self) -> AppResult<()> {
+        if let Some(parent) = self.database.path.parent() {
+            fs::create_dir_all(parent)?;
+        }
+        fs::create_dir_all(&self.repository.root)?;
+        Ok(())
+    }
+
+    fn absolutize_from(&mut self, base: &Path) {
+        if self.database.path.is_relative() {
+            self.database.path = base.join(&self.database.path);
+        }
+        if self.repository.root.is_relative() {
+            self.repository.root = base.join(&self.repository.root);
+        }
+    }
+}
+
+fn default_bind() -> String {
+    "127.0.0.1:3000".to_string()
+}
+
+fn default_external_url() -> String {
+    "http://127.0.0.1:3000/".to_string()
+}
+
+fn default_db_path() -> PathBuf {
+    PathBuf::from("./data/gitr.db")
+}
+
+fn default_repo_root() -> PathBuf {
+    PathBuf::from("./data/repositories")
+}
+
+fn default_branch() -> String {
+    "main".to_string()
+}
+
+fn default_git_binary() -> String {
+    "git".to_string()
+}
+
+fn default_run_user() -> String {
+    "git".to_string()
+}

+ 1068 - 0
src/db.rs

@@ -0,0 +1,1068 @@
+use std::{
+    path::Path,
+    sync::{Arc, Mutex},
+    time::{SystemTime, UNIX_EPOCH},
+};
+
+use rusqlite::{Connection, OptionalExtension, Transaction, params};
+
+use crate::{
+    error::{AppError, AppResult},
+    models::{
+        AccessMode, AccessToken, Collaboration, CollaboratorResponse, PullRequest,
+        PullRequestStatus, Repository, RepositoryWithOwner, User,
+    },
+};
+
+#[derive(Clone)]
+pub struct Database {
+    conn: Arc<Mutex<Connection>>,
+}
+
+impl Database {
+    pub fn open(path: &Path) -> AppResult<Self> {
+        let conn = Connection::open(path)?;
+        conn.execute_batch(
+            r#"
+            PRAGMA journal_mode = WAL;
+            PRAGMA foreign_keys = ON;
+            PRAGMA synchronous = NORMAL;
+            PRAGMA temp_store = MEMORY;
+            "#,
+        )?;
+        Ok(Self {
+            conn: Arc::new(Mutex::new(conn)),
+        })
+    }
+
+    pub fn init_schema(&self) -> AppResult<()> {
+        let conn = self.lock()?;
+        conn.execute_batch(
+            r#"
+            CREATE TABLE IF NOT EXISTS user (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                lower_name TEXT NOT NULL UNIQUE,
+                name TEXT NOT NULL UNIQUE,
+                full_name TEXT NOT NULL DEFAULT '',
+                email TEXT NOT NULL UNIQUE,
+                password_hash TEXT NOT NULL,
+                is_active INTEGER NOT NULL DEFAULT 1,
+                is_admin INTEGER NOT NULL DEFAULT 0,
+                created_unix INTEGER NOT NULL,
+                updated_unix INTEGER NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS repository (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                owner_id INTEGER NOT NULL,
+                lower_name TEXT NOT NULL,
+                name TEXT NOT NULL,
+                description TEXT NOT NULL DEFAULT '',
+                default_branch TEXT NOT NULL,
+                is_private INTEGER NOT NULL DEFAULT 0,
+                is_bare INTEGER NOT NULL DEFAULT 1,
+                is_fork INTEGER NOT NULL DEFAULT 0,
+                fork_id INTEGER NOT NULL DEFAULT 0,
+                created_unix INTEGER NOT NULL,
+                updated_unix INTEGER NOT NULL,
+                UNIQUE(owner_id, lower_name),
+                FOREIGN KEY(owner_id) REFERENCES user(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS access_token (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                name TEXT NOT NULL,
+                token_hash TEXT NOT NULL UNIQUE,
+                created_unix INTEGER NOT NULL,
+                updated_unix INTEGER NOT NULL DEFAULT 0,
+                FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
+            );
+
+            CREATE UNIQUE INDEX IF NOT EXISTS idx_access_token_user_name
+            ON access_token (user_id, name);
+
+            CREATE TABLE IF NOT EXISTS access (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                repo_id INTEGER NOT NULL,
+                mode INTEGER NOT NULL,
+                UNIQUE(user_id, repo_id),
+                FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
+                FOREIGN KEY(repo_id) REFERENCES repository(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS collaboration (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                repo_id INTEGER NOT NULL,
+                mode INTEGER NOT NULL,
+                UNIQUE(user_id, repo_id),
+                FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
+                FOREIGN KEY(repo_id) REFERENCES repository(id) ON DELETE CASCADE
+            );
+
+            CREATE TABLE IF NOT EXISTS pull_request (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                index_in_repo INTEGER NOT NULL,
+                title TEXT NOT NULL,
+                body TEXT NOT NULL DEFAULT '',
+                status INTEGER NOT NULL,
+                head_repo_id INTEGER NOT NULL,
+                base_repo_id INTEGER NOT NULL,
+                head_user_name TEXT NOT NULL,
+                head_branch TEXT NOT NULL,
+                base_branch TEXT NOT NULL,
+                merge_base TEXT NOT NULL,
+                merged_commit_id TEXT NOT NULL DEFAULT '',
+                poster_id INTEGER NOT NULL,
+                has_merged INTEGER NOT NULL DEFAULT 0,
+                is_closed INTEGER NOT NULL DEFAULT 0,
+                created_unix INTEGER NOT NULL,
+                updated_unix INTEGER NOT NULL,
+                FOREIGN KEY(head_repo_id) REFERENCES repository(id) ON DELETE CASCADE,
+                FOREIGN KEY(base_repo_id) REFERENCES repository(id) ON DELETE CASCADE,
+                FOREIGN KEY(poster_id) REFERENCES user(id) ON DELETE CASCADE
+            );
+
+            CREATE UNIQUE INDEX IF NOT EXISTS idx_pull_request_base_repo_index
+            ON pull_request (base_repo_id, index_in_repo);
+            "#,
+        )?;
+        ensure_column_exists(
+            &conn,
+            "pull_request",
+            "merged_commit_id",
+            "TEXT NOT NULL DEFAULT ''",
+        )?;
+        ensure_column_exists(
+            &conn,
+            "access_token",
+            "updated_unix",
+            "INTEGER NOT NULL DEFAULT 0",
+        )?;
+        Ok(())
+    }
+
+    pub fn create_user(&self, new_user: NewUser<'_>) -> AppResult<User> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+
+        let lower_name = new_user.username.to_ascii_lowercase();
+        let email = new_user.email.trim().to_ascii_lowercase();
+
+        if self.user_exists_by_lower_name(&tx, &lower_name)? {
+            return Err(AppError::Conflict(format!(
+                "user already exists: {}",
+                new_user.username
+            )));
+        }
+
+        if self.user_exists_by_email(&tx, &email)? {
+            return Err(AppError::Conflict(format!("email already used: {email}")));
+        }
+
+        let now = now_unix();
+        tx.execute(
+            r#"
+            INSERT INTO user (
+                lower_name, name, full_name, email, password_hash,
+                is_active, is_admin, created_unix, updated_unix
+            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
+            "#,
+            params![
+                lower_name,
+                new_user.username,
+                new_user.full_name,
+                email,
+                new_user.password_hash,
+                new_user.is_active,
+                new_user.is_admin,
+                now,
+                now
+            ],
+        )?;
+
+        let id = tx.last_insert_rowid();
+        tx.commit()?;
+        drop(conn);
+        self.get_user_by_id(id)?
+            .ok_or_else(|| AppError::NotFound(format!("user disappeared after create: {id}")))
+    }
+
+    pub fn user_count(&self) -> AppResult<i64> {
+        let conn = self.lock()?;
+        conn.query_row("SELECT COUNT(*) FROM user", [], |row| row.get::<_, i64>(0))
+            .map_err(Into::into)
+    }
+
+    pub fn get_user_by_id(&self, id: i64) -> AppResult<Option<User>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, lower_name, name, full_name, email, password_hash,
+                   is_active, is_admin, created_unix, updated_unix
+            FROM user WHERE id = ?1
+            "#,
+        )?;
+        stmt.query_row(params![id], row_to_user)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_user_by_username(&self, username: &str) -> AppResult<Option<User>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, lower_name, name, full_name, email, password_hash,
+                   is_active, is_admin, created_unix, updated_unix
+            FROM user WHERE lower_name = ?1
+            "#,
+        )?;
+        stmt.query_row(params![username.to_ascii_lowercase()], row_to_user)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_user_by_email(&self, email: &str) -> AppResult<Option<User>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, lower_name, name, full_name, email, password_hash,
+                   is_active, is_admin, created_unix, updated_unix
+            FROM user WHERE email = ?1
+            "#,
+        )?;
+        stmt.query_row(params![email.trim().to_ascii_lowercase()], row_to_user)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn create_repository(&self, new_repo: NewRepository<'_>) -> AppResult<Repository> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+
+        let lower_name = new_repo.name.to_ascii_lowercase();
+        if self.repo_exists_by_name(&tx, new_repo.owner_id, &lower_name)? {
+            return Err(AppError::Conflict(format!(
+                "repository already exists: {}/{}",
+                new_repo.owner_name, new_repo.name
+            )));
+        }
+
+        let now = now_unix();
+        tx.execute(
+            r#"
+            INSERT INTO repository (
+                owner_id, lower_name, name, description, default_branch,
+                is_private, is_bare, is_fork, fork_id, created_unix, updated_unix
+            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
+            "#,
+            params![
+                new_repo.owner_id,
+                lower_name,
+                new_repo.name,
+                new_repo.description,
+                new_repo.default_branch,
+                new_repo.is_private,
+                new_repo.is_bare,
+                new_repo.is_fork,
+                new_repo.fork_id,
+                now,
+                now
+            ],
+        )?;
+
+        let id = tx.last_insert_rowid();
+        tx.commit()?;
+        drop(conn);
+        self.get_repository_by_id(id)?
+            .ok_or_else(|| AppError::NotFound(format!("repository disappeared after create: {id}")))
+    }
+
+    pub fn get_repository_by_id(&self, id: i64) -> AppResult<Option<Repository>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, owner_id, lower_name, name, description, default_branch,
+                   is_private, is_bare, is_fork, fork_id, created_unix, updated_unix
+            FROM repository WHERE id = ?1
+            "#,
+        )?;
+        stmt.query_row(params![id], row_to_repo)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_repository_by_name(
+        &self,
+        owner_id: i64,
+        name: &str,
+    ) -> AppResult<Option<Repository>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, owner_id, lower_name, name, description, default_branch,
+                   is_private, is_bare, is_fork, fork_id, created_unix, updated_unix
+            FROM repository
+            WHERE owner_id = ?1 AND lower_name = ?2
+            "#,
+        )?;
+        stmt.query_row(params![owner_id, name.to_ascii_lowercase()], row_to_repo)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn list_repositories_with_owners(&self) -> AppResult<Vec<RepositoryWithOwner>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT
+                r.id, r.owner_id, r.lower_name, r.name, r.description, r.default_branch,
+                r.is_private, r.is_bare, r.is_fork, r.fork_id, r.created_unix, r.updated_unix,
+                u.id, u.lower_name, u.name, u.full_name, u.email, u.password_hash,
+                u.is_active, u.is_admin, u.created_unix, u.updated_unix
+            FROM repository r
+            JOIN user u ON u.id = r.owner_id
+            ORDER BY u.lower_name ASC, r.lower_name ASC
+            "#,
+        )?;
+        let rows = stmt.query_map([], row_to_repository_with_owner)?;
+        let mut repos = Vec::new();
+        for row in rows {
+            repos.push(row?);
+        }
+        Ok(repos)
+    }
+
+    pub fn list_repositories_with_owners_by_owner(
+        &self,
+        owner_id: i64,
+    ) -> AppResult<Vec<RepositoryWithOwner>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT
+                r.id, r.owner_id, r.lower_name, r.name, r.description, r.default_branch,
+                r.is_private, r.is_bare, r.is_fork, r.fork_id, r.created_unix, r.updated_unix,
+                u.id, u.lower_name, u.name, u.full_name, u.email, u.password_hash,
+                u.is_active, u.is_admin, u.created_unix, u.updated_unix
+            FROM repository r
+            JOIN user u ON u.id = r.owner_id
+            WHERE r.owner_id = ?1
+            ORDER BY r.lower_name ASC
+            "#,
+        )?;
+        let rows = stmt.query_map(params![owner_id], row_to_repository_with_owner)?;
+        let mut repos = Vec::new();
+        for row in rows {
+            repos.push(row?);
+        }
+        Ok(repos)
+    }
+
+    pub fn has_forked_by(&self, repo_id: i64, user_id: i64) -> AppResult<bool> {
+        let conn = self.lock()?;
+        let mut stmt =
+            conn.prepare("SELECT 1 FROM repository WHERE owner_id = ?1 AND fork_id = ?2 LIMIT 1")?;
+        let found = stmt
+            .query_row(params![user_id, repo_id], |row| row.get::<_, i64>(0))
+            .optional()?;
+        Ok(found.is_some())
+    }
+
+    pub fn delete_repository_by_id(&self, id: i64) -> AppResult<()> {
+        let conn = self.lock()?;
+        conn.execute("DELETE FROM repository WHERE id = ?1", params![id])?;
+        Ok(())
+    }
+
+    pub fn access_mode(
+        &self,
+        user_id: i64,
+        repo_id: i64,
+        owner_id: i64,
+        private: bool,
+    ) -> AppResult<AccessMode> {
+        if repo_id <= 0 {
+            return Ok(AccessMode::None);
+        }
+
+        let mut mode = if private {
+            AccessMode::None
+        } else {
+            AccessMode::Read
+        };
+
+        if user_id <= 0 {
+            return Ok(mode);
+        }
+        if user_id == owner_id {
+            return Ok(AccessMode::Owner);
+        }
+
+        let conn = self.lock()?;
+        let mut stmt =
+            conn.prepare("SELECT mode FROM access WHERE user_id = ?1 AND repo_id = ?2")?;
+        let found = stmt
+            .query_row(params![user_id, repo_id], |row| row.get::<_, i64>(0))
+            .optional()?;
+        if let Some(value) = found {
+            mode = access_mode_from_i64(value);
+        }
+        Ok(mode)
+    }
+
+    pub fn authorize(
+        &self,
+        user_id: i64,
+        repo_id: i64,
+        desired: AccessMode,
+        owner_id: i64,
+        private: bool,
+    ) -> AppResult<bool> {
+        Ok((desired as i64) <= (self.access_mode(user_id, repo_id, owner_id, private)? as i64))
+    }
+
+    pub fn set_repo_perms(&self, repo_id: i64, access_map: &[(i64, AccessMode)]) -> AppResult<()> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+        tx.execute("DELETE FROM access WHERE repo_id = ?1", params![repo_id])?;
+        for (user_id, mode) in access_map {
+            tx.execute(
+                "INSERT INTO access (user_id, repo_id, mode) VALUES (?1, ?2, ?3)",
+                params![user_id, repo_id, *mode as i64],
+            )?;
+        }
+        tx.commit()?;
+        Ok(())
+    }
+
+    pub fn upsert_collaboration(
+        &self,
+        repo_id: i64,
+        user_id: i64,
+        mode: AccessMode,
+    ) -> AppResult<Collaboration> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+        tx.execute(
+            r#"
+            INSERT INTO collaboration (user_id, repo_id, mode)
+            VALUES (?1, ?2, ?3)
+            ON CONFLICT(user_id, repo_id) DO UPDATE SET mode = excluded.mode
+            "#,
+            params![user_id, repo_id, mode as i64],
+        )?;
+        tx.execute(
+            r#"
+            INSERT INTO access (user_id, repo_id, mode)
+            VALUES (?1, ?2, ?3)
+            ON CONFLICT(user_id, repo_id) DO UPDATE SET mode = excluded.mode
+            "#,
+            params![user_id, repo_id, mode as i64],
+        )?;
+        let id = tx.query_row(
+            "SELECT id FROM collaboration WHERE user_id = ?1 AND repo_id = ?2",
+            params![user_id, repo_id],
+            |row| row.get::<_, i64>(0),
+        )?;
+        tx.commit()?;
+        drop(conn);
+        self.get_collaboration_by_id(id)?.ok_or_else(|| {
+            AppError::NotFound(format!("collaboration disappeared after upsert: {id}"))
+        })
+    }
+
+    pub fn get_collaboration_by_id(&self, id: i64) -> AppResult<Option<Collaboration>> {
+        let conn = self.lock()?;
+        let mut stmt =
+            conn.prepare("SELECT id, user_id, repo_id, mode FROM collaboration WHERE id = ?1")?;
+        stmt.query_row(params![id], row_to_collaboration)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_collaborator(
+        &self,
+        repo_id: i64,
+        user_id: i64,
+    ) -> AppResult<Option<CollaboratorResponse>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT
+                u.id, u.lower_name, u.name, u.full_name, u.email, u.password_hash,
+                u.is_active, u.is_admin, u.created_unix, u.updated_unix,
+                c.mode
+            FROM collaboration c
+            JOIN user u ON u.id = c.user_id
+            WHERE c.repo_id = ?1 AND c.user_id = ?2
+            LIMIT 1
+            "#,
+        )?;
+        stmt.query_row(params![repo_id, user_id], row_to_collaborator_response)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn list_collaborators(&self, repo_id: i64) -> AppResult<Vec<CollaboratorResponse>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT
+                u.id, u.lower_name, u.name, u.full_name, u.email, u.password_hash,
+                u.is_active, u.is_admin, u.created_unix, u.updated_unix,
+                c.mode
+            FROM collaboration c
+            JOIN user u ON u.id = c.user_id
+            WHERE c.repo_id = ?1
+            ORDER BY u.lower_name ASC
+            "#,
+        )?;
+        let rows = stmt.query_map(params![repo_id], row_to_collaborator_response)?;
+        let mut collaborators = Vec::new();
+        for row in rows {
+            collaborators.push(row?);
+        }
+        Ok(collaborators)
+    }
+
+    pub fn delete_collaboration(&self, repo_id: i64, user_id: i64) -> AppResult<()> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+        tx.execute(
+            "DELETE FROM collaboration WHERE repo_id = ?1 AND user_id = ?2",
+            params![repo_id, user_id],
+        )?;
+        tx.execute(
+            "DELETE FROM access WHERE repo_id = ?1 AND user_id = ?2",
+            params![repo_id, user_id],
+        )?;
+        tx.commit()?;
+        Ok(())
+    }
+
+    pub fn create_access_token(
+        &self,
+        user_id: i64,
+        name: &str,
+        token_hash: &str,
+    ) -> AppResult<AccessToken> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+        if self.access_token_exists_by_name(&tx, user_id, name)? {
+            return Err(AppError::Conflict(format!(
+                "access token already exists: {name}"
+            )));
+        }
+        let now = now_unix();
+        tx.execute(
+            r#"
+            INSERT INTO access_token (user_id, name, token_hash, created_unix, updated_unix)
+            VALUES (?1, ?2, ?3, ?4, 0)
+            "#,
+            params![user_id, name, token_hash, now],
+        )?;
+        let id = tx.last_insert_rowid();
+        tx.commit()?;
+        drop(conn);
+        self.get_access_token_by_id(id)?.ok_or_else(|| {
+            AppError::NotFound(format!("access token disappeared after create: {id}"))
+        })
+    }
+
+    pub fn get_access_token_by_id(&self, id: i64) -> AppResult<Option<AccessToken>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, user_id, name, token_hash, created_unix, updated_unix
+            FROM access_token WHERE id = ?1
+            "#,
+        )?;
+        stmt.query_row(params![id], row_to_access_token)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_access_token_by_hash(&self, token_hash: &str) -> AppResult<Option<AccessToken>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, user_id, name, token_hash, created_unix, updated_unix
+            FROM access_token WHERE token_hash = ?1
+            "#,
+        )?;
+        stmt.query_row(params![token_hash], row_to_access_token)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn list_access_tokens_by_user(&self, user_id: i64) -> AppResult<Vec<AccessToken>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, user_id, name, token_hash, created_unix, updated_unix
+            FROM access_token
+            WHERE user_id = ?1
+            ORDER BY id ASC
+            "#,
+        )?;
+        let rows = stmt.query_map(params![user_id], row_to_access_token)?;
+        let mut tokens = Vec::new();
+        for row in rows {
+            tokens.push(row?);
+        }
+        Ok(tokens)
+    }
+
+    pub fn delete_access_token_by_id(&self, user_id: i64, token_id: i64) -> AppResult<bool> {
+        let conn = self.lock()?;
+        let affected = conn.execute(
+            "DELETE FROM access_token WHERE id = ?1 AND user_id = ?2",
+            params![token_id, user_id],
+        )?;
+        Ok(affected > 0)
+    }
+
+    pub fn touch_access_token(&self, token_id: i64) -> AppResult<()> {
+        let conn = self.lock()?;
+        conn.execute(
+            "UPDATE access_token SET updated_unix = ?2 WHERE id = ?1",
+            params![token_id, now_unix()],
+        )?;
+        Ok(())
+    }
+
+    pub fn create_pull_request(&self, new_pull: NewPullRequest<'_>) -> AppResult<PullRequest> {
+        let conn = self.lock()?;
+        let tx = conn.unchecked_transaction()?;
+        let now = now_unix();
+        let index = tx.query_row(
+            "SELECT COALESCE(MAX(index_in_repo), 0) + 1 FROM pull_request WHERE base_repo_id = ?1",
+            params![new_pull.base_repo_id],
+            |row| row.get::<_, i64>(0),
+        )?;
+        tx.execute(
+            r#"
+            INSERT INTO pull_request (
+                index_in_repo, title, body, status, head_repo_id, base_repo_id,
+                head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
+                has_merged, is_closed, created_unix, updated_unix
+            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '', ?11, 0, 0, ?12, ?13)
+            "#,
+            params![
+                index,
+                new_pull.title,
+                new_pull.body,
+                new_pull.status as i64,
+                new_pull.head_repo_id,
+                new_pull.base_repo_id,
+                new_pull.head_user_name,
+                new_pull.head_branch,
+                new_pull.base_branch,
+                new_pull.merge_base,
+                new_pull.poster_id,
+                now,
+                now
+            ],
+        )?;
+        let id = tx.last_insert_rowid();
+        tx.commit()?;
+        drop(conn);
+        self.get_pull_request_by_id(id)?.ok_or_else(|| {
+            AppError::NotFound(format!("pull request disappeared after create: {id}"))
+        })
+    }
+
+    pub fn get_pull_request_by_id(&self, id: i64) -> AppResult<Option<PullRequest>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
+                   head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
+                   has_merged, is_closed, created_unix, updated_unix
+            FROM pull_request WHERE id = ?1
+            "#,
+        )?;
+        stmt.query_row(params![id], row_to_pull_request)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn get_unmerged_pull_request(
+        &self,
+        head_repo_id: i64,
+        base_repo_id: i64,
+        head_branch: &str,
+        base_branch: &str,
+    ) -> AppResult<Option<PullRequest>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
+                   head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
+                   has_merged, is_closed, created_unix, updated_unix
+            FROM pull_request
+            WHERE head_repo_id = ?1 AND base_repo_id = ?2
+              AND head_branch = ?3 AND base_branch = ?4
+              AND has_merged = 0 AND is_closed = 0
+            LIMIT 1
+            "#,
+        )?;
+        stmt.query_row(
+            params![head_repo_id, base_repo_id, head_branch, base_branch],
+            row_to_pull_request,
+        )
+        .optional()
+        .map_err(Into::into)
+    }
+
+    pub fn get_pull_request_by_base_repo_and_index(
+        &self,
+        base_repo_id: i64,
+        index: i64,
+    ) -> AppResult<Option<PullRequest>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
+                   head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
+                   has_merged, is_closed, created_unix, updated_unix
+            FROM pull_request
+            WHERE base_repo_id = ?1 AND index_in_repo = ?2
+            LIMIT 1
+            "#,
+        )?;
+        stmt.query_row(params![base_repo_id, index], row_to_pull_request)
+            .optional()
+            .map_err(Into::into)
+    }
+
+    pub fn list_pull_requests_by_base_repo(
+        &self,
+        base_repo_id: i64,
+    ) -> AppResult<Vec<PullRequest>> {
+        let conn = self.lock()?;
+        let mut stmt = conn.prepare(
+            r#"
+            SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
+                   head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
+                   has_merged, is_closed, created_unix, updated_unix
+            FROM pull_request
+            WHERE base_repo_id = ?1
+            ORDER BY index_in_repo ASC
+            "#,
+        )?;
+        let rows = stmt.query_map(params![base_repo_id], row_to_pull_request)?;
+        let mut pulls = Vec::new();
+        for row in rows {
+            pulls.push(row?);
+        }
+        Ok(pulls)
+    }
+
+    pub fn mark_pull_request_merged(
+        &self,
+        id: i64,
+        merged_commit_id: &str,
+    ) -> AppResult<PullRequest> {
+        let conn = self.lock()?;
+        let now = now_unix();
+        conn.execute(
+            r#"
+            UPDATE pull_request
+            SET has_merged = 1, is_closed = 1, merged_commit_id = ?2, updated_unix = ?3
+            WHERE id = ?1
+            "#,
+            params![id, merged_commit_id, now],
+        )?;
+        drop(conn);
+        self.get_pull_request_by_id(id)?.ok_or_else(|| {
+            AppError::NotFound(format!("pull request disappeared after merge: {id}"))
+        })
+    }
+
+    pub fn update_pull_request_open_state(
+        &self,
+        id: i64,
+        is_closed: bool,
+        status: PullRequestStatus,
+    ) -> AppResult<PullRequest> {
+        let conn = self.lock()?;
+        let now = now_unix();
+        conn.execute(
+            r#"
+            UPDATE pull_request
+            SET is_closed = ?2, status = ?3, updated_unix = ?4
+            WHERE id = ?1
+            "#,
+            params![id, is_closed, status as i64, now],
+        )?;
+        drop(conn);
+        self.get_pull_request_by_id(id)?.ok_or_else(|| {
+            AppError::NotFound(format!("pull request disappeared after state update: {id}"))
+        })
+    }
+
+    fn user_exists_by_lower_name(&self, tx: &Transaction<'_>, lower_name: &str) -> AppResult<bool> {
+        let found = tx
+            .query_row(
+                "SELECT 1 FROM user WHERE lower_name = ?1 LIMIT 1",
+                params![lower_name],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        Ok(found.is_some())
+    }
+
+    fn user_exists_by_email(&self, tx: &Transaction<'_>, email: &str) -> AppResult<bool> {
+        let found = tx
+            .query_row(
+                "SELECT 1 FROM user WHERE email = ?1 LIMIT 1",
+                params![email],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        Ok(found.is_some())
+    }
+
+    fn repo_exists_by_name(
+        &self,
+        tx: &Transaction<'_>,
+        owner_id: i64,
+        lower_name: &str,
+    ) -> AppResult<bool> {
+        let found = tx
+            .query_row(
+                "SELECT 1 FROM repository WHERE owner_id = ?1 AND lower_name = ?2 LIMIT 1",
+                params![owner_id, lower_name],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        Ok(found.is_some())
+    }
+
+    fn access_token_exists_by_name(
+        &self,
+        tx: &Transaction<'_>,
+        user_id: i64,
+        name: &str,
+    ) -> AppResult<bool> {
+        let found = tx
+            .query_row(
+                "SELECT 1 FROM access_token WHERE user_id = ?1 AND name = ?2 LIMIT 1",
+                params![user_id, name],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        Ok(found.is_some())
+    }
+
+    fn lock(&self) -> AppResult<std::sync::MutexGuard<'_, Connection>> {
+        self.conn
+            .lock()
+            .map_err(|_| AppError::Db(rusqlite::Error::InvalidQuery))
+    }
+}
+
+pub struct NewUser<'a> {
+    pub username: &'a str,
+    pub full_name: &'a str,
+    pub email: &'a str,
+    pub password_hash: &'a str,
+    pub is_active: bool,
+    pub is_admin: bool,
+}
+
+pub struct NewRepository<'a> {
+    pub owner_id: i64,
+    pub owner_name: &'a str,
+    pub name: &'a str,
+    pub description: &'a str,
+    pub default_branch: &'a str,
+    pub is_private: bool,
+    pub is_bare: bool,
+    pub is_fork: bool,
+    pub fork_id: i64,
+}
+
+pub struct NewPullRequest<'a> {
+    pub title: &'a str,
+    pub body: &'a str,
+    pub status: PullRequestStatus,
+    pub head_repo_id: i64,
+    pub base_repo_id: i64,
+    pub head_user_name: &'a str,
+    pub head_branch: &'a str,
+    pub base_branch: &'a str,
+    pub merge_base: &'a str,
+    pub poster_id: i64,
+}
+
+fn row_to_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<User> {
+    Ok(User {
+        id: row.get(0)?,
+        lower_name: row.get(1)?,
+        name: row.get(2)?,
+        full_name: row.get(3)?,
+        email: row.get(4)?,
+        password_hash: row.get(5)?,
+        is_active: row.get(6)?,
+        is_admin: row.get(7)?,
+        created_unix: row.get(8)?,
+        updated_unix: row.get(9)?,
+    })
+}
+
+fn row_to_user_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<User> {
+    Ok(User {
+        id: row.get(offset)?,
+        lower_name: row.get(offset + 1)?,
+        name: row.get(offset + 2)?,
+        full_name: row.get(offset + 3)?,
+        email: row.get(offset + 4)?,
+        password_hash: row.get(offset + 5)?,
+        is_active: row.get(offset + 6)?,
+        is_admin: row.get(offset + 7)?,
+        created_unix: row.get(offset + 8)?,
+        updated_unix: row.get(offset + 9)?,
+    })
+}
+
+fn row_to_repo(row: &rusqlite::Row<'_>) -> rusqlite::Result<Repository> {
+    Ok(Repository {
+        id: row.get(0)?,
+        owner_id: row.get(1)?,
+        lower_name: row.get(2)?,
+        name: row.get(3)?,
+        description: row.get(4)?,
+        default_branch: row.get(5)?,
+        is_private: row.get(6)?,
+        is_bare: row.get(7)?,
+        is_fork: row.get(8)?,
+        fork_id: row.get(9)?,
+        created_unix: row.get(10)?,
+        updated_unix: row.get(11)?,
+    })
+}
+
+fn row_to_repo_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<Repository> {
+    Ok(Repository {
+        id: row.get(offset)?,
+        owner_id: row.get(offset + 1)?,
+        lower_name: row.get(offset + 2)?,
+        name: row.get(offset + 3)?,
+        description: row.get(offset + 4)?,
+        default_branch: row.get(offset + 5)?,
+        is_private: row.get(offset + 6)?,
+        is_bare: row.get(offset + 7)?,
+        is_fork: row.get(offset + 8)?,
+        fork_id: row.get(offset + 9)?,
+        created_unix: row.get(offset + 10)?,
+        updated_unix: row.get(offset + 11)?,
+    })
+}
+
+fn row_to_repository_with_owner(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryWithOwner> {
+    Ok(RepositoryWithOwner {
+        repo: row_to_repo_at(row, 0)?,
+        owner: row_to_user_at(row, 12)?,
+    })
+}
+
+fn row_to_access_token(row: &rusqlite::Row<'_>) -> rusqlite::Result<AccessToken> {
+    Ok(AccessToken {
+        id: row.get(0)?,
+        user_id: row.get(1)?,
+        name: row.get(2)?,
+        token_hash: row.get(3)?,
+        created_unix: row.get(4)?,
+        updated_unix: row.get(5)?,
+    })
+}
+
+fn row_to_collaborator_response(
+    row: &rusqlite::Row<'_>,
+) -> rusqlite::Result<CollaboratorResponse> {
+    Ok(CollaboratorResponse {
+        user: row_to_user_at(row, 0)?,
+        mode: access_mode_from_i64(row.get::<_, i64>(10)?),
+    })
+}
+
+fn row_to_pull_request(row: &rusqlite::Row<'_>) -> rusqlite::Result<PullRequest> {
+    Ok(PullRequest {
+        id: row.get(0)?,
+        index: row.get(1)?,
+        title: row.get(2)?,
+        body: row.get(3)?,
+        status: pull_request_status_from_i64(row.get::<_, i64>(4)?),
+        head_repo_id: row.get(5)?,
+        base_repo_id: row.get(6)?,
+        head_user_name: row.get(7)?,
+        head_branch: row.get(8)?,
+        base_branch: row.get(9)?,
+        merge_base: row.get(10)?,
+        merged_commit_id: row.get(11)?,
+        poster_id: row.get(12)?,
+        has_merged: row.get(13)?,
+        is_closed: row.get(14)?,
+        created_unix: row.get(15)?,
+        updated_unix: row.get(16)?,
+    })
+}
+
+fn row_to_collaboration(row: &rusqlite::Row<'_>) -> rusqlite::Result<Collaboration> {
+    Ok(Collaboration {
+        id: row.get(0)?,
+        user_id: row.get(1)?,
+        repo_id: row.get(2)?,
+        mode: access_mode_from_i64(row.get::<_, i64>(3)?),
+    })
+}
+
+fn access_mode_from_i64(value: i64) -> AccessMode {
+    match value {
+        1 => AccessMode::Read,
+        2 => AccessMode::Write,
+        3 => AccessMode::Admin,
+        4 => AccessMode::Owner,
+        _ => AccessMode::None,
+    }
+}
+
+fn pull_request_status_from_i64(value: i64) -> PullRequestStatus {
+    match value {
+        0 => PullRequestStatus::Conflict,
+        1 => PullRequestStatus::Checking,
+        _ => PullRequestStatus::Mergeable,
+    }
+}
+
+fn now_unix() -> i64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs() as i64
+}
+
+fn ensure_column_exists(
+    conn: &Connection,
+    table_name: &str,
+    column_name: &str,
+    column_sql: &str,
+) -> AppResult<()> {
+    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?;
+    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
+    for row in rows {
+        if row? == column_name {
+            return Ok(());
+        }
+    }
+    conn.execute(
+        &format!("ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}"),
+        [],
+    )?;
+    Ok(())
+}

+ 87 - 0
src/error.rs

@@ -0,0 +1,87 @@
+use std::io;
+
+use actix_web::{HttpResponse, ResponseError, http::StatusCode};
+use serde::Serialize;
+use thiserror::Error;
+
+pub type AppResult<T> = Result<T, AppError>;
+
+#[derive(Debug, Error)]
+pub enum AppError {
+    #[error("I/O error: {0}")]
+    Io(#[from] io::Error),
+    #[error("database error: {0}")]
+    Db(#[from] rusqlite::Error),
+    #[error("configuration parse error: {0}")]
+    Config(toml::de::Error),
+    #[error("validation error: {0}")]
+    Validation(String),
+    #[error("conflict: {0}")]
+    Conflict(String),
+    #[error("not found: {0}")]
+    NotFound(String),
+    #[error("unauthorized: {0}")]
+    Unauthorized(String),
+    #[error("forbidden: {0}")]
+    Forbidden(String),
+    #[error("git error: {0}")]
+    Git(String),
+}
+
+#[derive(Serialize)]
+struct ErrorBody {
+    code: &'static str,
+    message: String,
+    status: u16,
+}
+
+impl ResponseError for AppError {
+    fn status_code(&self) -> StatusCode {
+        match self {
+            AppError::Validation(_) => StatusCode::BAD_REQUEST,
+            AppError::Conflict(_) => StatusCode::CONFLICT,
+            AppError::NotFound(_) => StatusCode::NOT_FOUND,
+            AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
+            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
+            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+                StatusCode::INTERNAL_SERVER_ERROR
+            }
+        }
+    }
+
+    fn error_response(&self) -> HttpResponse {
+        HttpResponse::build(self.status_code()).json(ErrorBody {
+            code: self.code(),
+            message: self.public_message(),
+            status: self.status_code().as_u16(),
+        })
+    }
+}
+
+impl AppError {
+    fn code(&self) -> &'static str {
+        match self {
+            AppError::Validation(_) => "validation_error",
+            AppError::Conflict(_) => "conflict",
+            AppError::NotFound(_) => "not_found",
+            AppError::Unauthorized(_) => "unauthorized",
+            AppError::Forbidden(_) => "forbidden",
+            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+                "internal_error"
+            }
+        }
+    }
+
+    fn public_message(&self) -> String {
+        match self {
+            AppError::Validation(message)
+            | AppError::Conflict(message)
+            | AppError::NotFound(message)
+            | AppError::Unauthorized(message)
+            | AppError::Forbidden(message) => message.clone(),
+            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+                "internal server error".to_string()
+            }
+        }
+    }
+}

+ 630 - 0
src/git.rs

@@ -0,0 +1,630 @@
+use std::{
+    fs,
+    io::Write,
+    path::Path,
+    process::{Command, Stdio},
+};
+
+use crate::error::{AppError, AppResult};
+
+#[derive(Debug, Clone)]
+pub struct CompareCommit {
+    pub id: String,
+    pub summary: String,
+    pub author_name: String,
+    pub author_email: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct CompareFile {
+    pub path: String,
+    pub additions: i64,
+    pub deletions: i64,
+}
+
+#[derive(Debug, Clone)]
+pub struct CompareResult {
+    pub merge_base: String,
+    pub head_commit_id: String,
+    pub commits: Vec<CompareCommit>,
+    pub files: Vec<CompareFile>,
+}
+
+pub fn init_bare_repo_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    default_branch: &str,
+) -> AppResult<()> {
+    if let Some(parent) = repo_path.parent() {
+        fs::create_dir_all(parent)?;
+    }
+
+    run(Command::new(git_binary)
+        .arg("init")
+        .arg("--bare")
+        .arg(repo_path))?;
+    run(Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("symbolic-ref")
+        .arg("HEAD")
+        .arg(format!("refs/heads/{default_branch}")))?;
+    run(Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("update-server-info"))?;
+    Ok(())
+}
+
+pub fn create_initial_commit_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    default_branch: &str,
+    readme: &str,
+    author_name: &str,
+    author_email: &str,
+) -> AppResult<()> {
+    let worktree = repo_path.with_extension("work");
+    if worktree.exists() {
+        fs::remove_dir_all(&worktree)?;
+    }
+    fs::create_dir_all(&worktree)?;
+
+    fs::write(worktree.join("README.md"), readme)?;
+
+    run(Command::new(git_binary)
+        .arg("init")
+        .arg("--initial-branch")
+        .arg(default_branch)
+        .arg(&worktree))?;
+    run(Command::new(git_binary)
+        .current_dir(&worktree)
+        .env("GIT_AUTHOR_NAME", author_name)
+        .env("GIT_AUTHOR_EMAIL", author_email)
+        .env("GIT_COMMITTER_NAME", author_name)
+        .env("GIT_COMMITTER_EMAIL", author_email)
+        .arg("add")
+        .arg("README.md"))?;
+    run(Command::new(git_binary)
+        .current_dir(&worktree)
+        .env("GIT_AUTHOR_NAME", author_name)
+        .env("GIT_AUTHOR_EMAIL", author_email)
+        .env("GIT_COMMITTER_NAME", author_name)
+        .env("GIT_COMMITTER_EMAIL", author_email)
+        .arg("commit")
+        .arg("-m")
+        .arg("Initial commit"))?;
+    run(Command::new(git_binary)
+        .current_dir(&worktree)
+        .arg("remote")
+        .arg("add")
+        .arg("origin")
+        .arg(repo_path))?;
+    run(Command::new(git_binary)
+        .current_dir(&worktree)
+        .arg("push")
+        .arg("origin")
+        .arg(default_branch))?;
+
+    fs::remove_dir_all(&worktree)?;
+    Ok(())
+}
+
+pub fn clone_bare_repo_with_binary(
+    git_binary: &str,
+    source_repo_path: &Path,
+    target_repo_path: &Path,
+) -> AppResult<()> {
+    if let Some(parent) = target_repo_path.parent() {
+        fs::create_dir_all(parent)?;
+    }
+
+    run(Command::new(git_binary)
+        .arg("clone")
+        .arg("--bare")
+        .arg(source_repo_path)
+        .arg(target_repo_path))?;
+    run(Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(target_repo_path)
+        .arg("update-server-info"))?;
+    Ok(())
+}
+
+pub fn list_branches_with_binary(git_binary: &str, repo_path: &Path) -> AppResult<Vec<String>> {
+    let output = Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("for-each-ref")
+        .arg("--format=%(refname:short)")
+        .arg("refs/heads")
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    Ok(stdout
+        .lines()
+        .map(str::trim)
+        .filter(|line| !line.is_empty())
+        .map(ToOwned::to_owned)
+        .collect())
+}
+
+pub fn branch_exists_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    branch: &str,
+) -> AppResult<bool> {
+    let branches = list_branches_with_binary(git_binary, repo_path)?;
+    Ok(branches.iter().any(|name| name == branch))
+}
+
+pub fn merge_base_with_binary(
+    git_binary: &str,
+    base_repo_path: &Path,
+    base_branch: &str,
+    head_repo_path: &Path,
+    head_branch: &str,
+) -> AppResult<String> {
+    let temp = std::env::temp_dir().join(format!(
+        "gitr-merge-base-{}",
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_nanos()
+    ));
+    run(Command::new(git_binary)
+        .arg("clone")
+        .arg("--bare")
+        .arg(base_repo_path)
+        .arg(&temp))?;
+    let result = (|| -> AppResult<String> {
+        run(Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(&temp)
+            .arg("remote")
+            .arg("add")
+            .arg("head_repo")
+            .arg(head_repo_path))?;
+        run(Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(&temp)
+            .arg("fetch")
+            .arg("head_repo")
+            .arg(head_branch))?;
+        let output = Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(&temp)
+            .arg("merge-base")
+            .arg(format!("refs/heads/{base_branch}"))
+            .arg("FETCH_HEAD")
+            .output()
+            .map_err(|err| AppError::Git(err.to_string()))?;
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+            return Err(AppError::Git(stderr));
+        }
+        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+    })();
+    let _ = fs::remove_dir_all(&temp);
+    result
+}
+
+pub fn test_merge_with_binary(
+    git_binary: &str,
+    base_repo_path: &Path,
+    base_branch: &str,
+    head_repo_path: &Path,
+    head_branch: &str,
+) -> AppResult<bool> {
+    let temp = temp_git_path("gitr-test-merge");
+    run(Command::new(git_binary)
+        .arg("clone")
+        .arg("-b")
+        .arg(base_branch)
+        .arg(base_repo_path)
+        .arg(&temp))?;
+    let result = (|| -> AppResult<bool> {
+    run(Command::new(git_binary)
+        .current_dir(&temp)
+        .arg("remote")
+            .arg("add")
+            .arg("head_repo")
+            .arg(head_repo_path))?;
+        run(Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("fetch")
+            .arg("head_repo")
+            .arg(head_branch))?;
+
+        let output = Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("merge")
+            .arg("--no-ff")
+            .arg("--no-commit")
+            .arg("FETCH_HEAD")
+            .output()
+            .map_err(|err| AppError::Git(err.to_string()))?;
+        Ok(output.status.success())
+    })();
+    let _ = fs::remove_dir_all(&temp);
+    result
+}
+
+pub fn merge_pull_request_with_binary(
+    git_binary: &str,
+    base_repo_path: &Path,
+    base_branch: &str,
+    head_repo_path: &Path,
+    head_branch: &str,
+    author_name: &str,
+    author_email: &str,
+    message: &str,
+) -> AppResult<String> {
+    let temp = temp_git_path("gitr-merge-pr");
+    run(Command::new(git_binary)
+        .arg("clone")
+        .arg("-b")
+        .arg(base_branch)
+        .arg(base_repo_path)
+        .arg(&temp))?;
+    let result = (|| -> AppResult<String> {
+        run(Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("remote")
+            .arg("add")
+            .arg("head_repo")
+            .arg(head_repo_path))?;
+        run(Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("fetch")
+            .arg("head_repo")
+            .arg(head_branch))?;
+        let head_commit_id = rev_parse_with_binary(git_binary, &temp.join(".git"), "FETCH_HEAD")?;
+
+        let merge = Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("merge")
+            .arg("--no-ff")
+            .arg("--no-commit")
+            .arg("FETCH_HEAD")
+            .output()
+            .map_err(|err| AppError::Git(err.to_string()))?;
+        if !merge.status.success() {
+            let stderr = String::from_utf8_lossy(&merge.stderr).trim().to_string();
+            let stdout = String::from_utf8_lossy(&merge.stdout).trim().to_string();
+            let detail = if !stderr.is_empty() { stderr } else { stdout };
+            return Err(AppError::Conflict(format!(
+                "pull request cannot be merged automatically: {detail}"
+            )));
+        }
+
+        run(Command::new(git_binary)
+            .current_dir(&temp)
+            .env("GIT_AUTHOR_NAME", author_name)
+            .env("GIT_AUTHOR_EMAIL", author_email)
+            .env("GIT_COMMITTER_NAME", author_name)
+            .env("GIT_COMMITTER_EMAIL", author_email)
+            .arg("commit")
+            .arg("-m")
+            .arg(message))?;
+        run(Command::new(git_binary)
+            .current_dir(&temp)
+            .arg("push")
+            .arg("origin")
+            .arg(base_branch))?;
+        run(Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(base_repo_path)
+            .arg("update-server-info"))?;
+        Ok(head_commit_id)
+    })();
+    let _ = fs::remove_dir_all(&temp);
+    result
+}
+
+pub fn compare_refs_with_binary(
+    git_binary: &str,
+    base_repo_path: &Path,
+    base_branch: &str,
+    head_repo_path: &Path,
+    head_branch: &str,
+) -> AppResult<CompareResult> {
+    let temp = temp_git_path("gitr-compare-refs");
+    run(Command::new(git_binary)
+        .arg("clone")
+        .arg("--bare")
+        .arg(base_repo_path)
+        .arg(&temp))?;
+    let result = (|| -> AppResult<CompareResult> {
+        run(Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(&temp)
+            .arg("remote")
+            .arg("add")
+            .arg("head_repo")
+            .arg(head_repo_path))?;
+        run(Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(&temp)
+            .arg("fetch")
+            .arg("head_repo")
+            .arg(head_branch))?;
+
+        let merge_base = merge_base_for_repo(git_binary, &temp, base_branch, "FETCH_HEAD")?;
+        let head_commit_id = rev_parse_with_binary(git_binary, &temp, "FETCH_HEAD")?;
+        compare_rev_range_with_binary(git_binary, &temp, &merge_base, &head_commit_id)
+    })();
+    let _ = fs::remove_dir_all(&temp);
+    result
+}
+
+pub fn compare_rev_range_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    merge_base: &str,
+    head_commit_id: &str,
+) -> AppResult<CompareResult> {
+    let commits =
+        list_commits_between_with_binary(git_binary, repo_path, merge_base, head_commit_id)?;
+    let files = if merge_base == head_commit_id {
+        Vec::new()
+    } else {
+        diff_numstat_with_binary(git_binary, repo_path, merge_base, head_commit_id)?
+    };
+    Ok(CompareResult {
+        merge_base: merge_base.to_string(),
+        head_commit_id: head_commit_id.to_string(),
+        commits,
+        files,
+    })
+}
+
+pub fn rev_parse_with_binary(git_binary: &str, repo_path: &Path, rev: &str) -> AppResult<String> {
+    let output = Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("rev-parse")
+        .arg(rev)
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+fn run(command: &mut Command) -> AppResult<()> {
+    let output = command
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if output.status.success() {
+        return Ok(());
+    }
+
+    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    let message = if !stderr.is_empty() { stderr } else { stdout };
+    Err(AppError::Git(message))
+}
+
+fn temp_git_path(prefix: &str) -> std::path::PathBuf {
+    std::env::temp_dir().join(format!(
+        "{prefix}-{}",
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_nanos()
+    ))
+}
+
+fn merge_base_for_repo(
+    git_binary: &str,
+    repo_path: &Path,
+    base_branch: &str,
+    head_rev: &str,
+) -> AppResult<String> {
+    let output = Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("merge-base")
+        .arg(format!("refs/heads/{base_branch}"))
+        .arg(head_rev)
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+fn list_commits_between_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    merge_base: &str,
+    head_commit_id: &str,
+) -> AppResult<Vec<CompareCommit>> {
+    if merge_base == head_commit_id {
+        return Ok(Vec::new());
+    }
+
+    let output = Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("log")
+        .arg("--reverse")
+        .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1e")
+        .arg(format!("{merge_base}..{head_commit_id}"))
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let mut commits = Vec::new();
+    for record in stdout.split('\x1e') {
+        let record = record.trim();
+        if record.is_empty() {
+            continue;
+        }
+        let mut fields = record.split('\x1f');
+        let id = fields.next().unwrap_or_default().to_string();
+        let summary = fields.next().unwrap_or_default().to_string();
+        let author_name = fields.next().unwrap_or_default().to_string();
+        let author_email = fields.next().unwrap_or_default().to_string();
+        commits.push(CompareCommit {
+            id,
+            summary,
+            author_name,
+            author_email,
+        });
+    }
+    Ok(commits)
+}
+
+fn diff_numstat_with_binary(
+    git_binary: &str,
+    repo_path: &Path,
+    merge_base: &str,
+    head_commit_id: &str,
+) -> AppResult<Vec<CompareFile>> {
+    let output = Command::new(git_binary)
+        .arg("--git-dir")
+        .arg(repo_path)
+        .arg("diff")
+        .arg("--numstat")
+        .arg(merge_base)
+        .arg(head_commit_id)
+        .output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let mut files = Vec::new();
+    for line in stdout.lines() {
+        if line.trim().is_empty() {
+            continue;
+        }
+        let mut parts = line.splitn(3, '\t');
+        let additions = parse_numstat_value(parts.next().unwrap_or_default());
+        let deletions = parse_numstat_value(parts.next().unwrap_or_default());
+        let path = parts.next().unwrap_or_default().to_string();
+        files.push(CompareFile {
+            path,
+            additions,
+            deletions,
+        });
+    }
+    Ok(files)
+}
+
+fn parse_numstat_value(value: &str) -> i64 {
+    value.parse::<i64>().unwrap_or(0)
+}
+
+pub struct GitHttpBackendRequest<'a> {
+    pub git_binary: &'a str,
+    pub project_root: &'a Path,
+    pub path_info: &'a str,
+    pub method: &'a str,
+    pub query_string: &'a str,
+    pub content_type: Option<&'a str>,
+    pub remote_user: Option<&'a str>,
+    pub body: &'a [u8],
+}
+
+pub struct GitHttpBackendResponse {
+    pub status_code: u16,
+    pub headers: Vec<(String, String)>,
+    pub body: Vec<u8>,
+}
+
+pub fn run_git_http_backend(req: GitHttpBackendRequest<'_>) -> AppResult<GitHttpBackendResponse> {
+    let mut command = Command::new(req.git_binary);
+    command
+        .arg("http-backend")
+        .env("GIT_PROJECT_ROOT", req.project_root)
+        .env("GIT_HTTP_EXPORT_ALL", "1")
+        .env("PATH_INFO", req.path_info)
+        .env("REQUEST_METHOD", req.method)
+        .env("QUERY_STRING", req.query_string)
+        .env("CONTENT_LENGTH", req.body.len().to_string())
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped());
+    if let Some(content_type) = req.content_type {
+        command.env("CONTENT_TYPE", content_type);
+    }
+    if let Some(remote_user) = req.remote_user {
+        command.env("REMOTE_USER", remote_user);
+    }
+    let mut child = command
+        .spawn()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+
+    if let Some(mut stdin) = child.stdin.take() {
+        stdin.write_all(req.body)?;
+    }
+
+    let output = child
+        .wait_with_output()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(AppError::Git(stderr));
+    }
+
+    parse_git_http_backend_output(&output.stdout)
+}
+
+fn parse_git_http_backend_output(stdout: &[u8]) -> AppResult<GitHttpBackendResponse> {
+    let split = stdout
+        .windows(4)
+        .position(|w| w == b"\r\n\r\n")
+        .map(|i| (i, 4))
+        .or_else(|| stdout.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2)))
+        .ok_or_else(|| AppError::Git("invalid git-http-backend response".to_string()))?;
+
+    let header_bytes = &stdout[..split.0];
+    let body = stdout[split.0 + split.1..].to_vec();
+    let header_text = String::from_utf8_lossy(header_bytes);
+
+    let mut status_code = 200;
+    let mut headers = Vec::new();
+    for line in header_text.lines() {
+        if line.is_empty() {
+            continue;
+        }
+        if let Some(value) = line.strip_prefix("Status:") {
+            let code = value
+                .split_whitespace()
+                .next()
+                .and_then(|v| v.parse::<u16>().ok())
+                .ok_or_else(|| AppError::Git(format!("invalid status line: {line}")))?;
+            status_code = code;
+            continue;
+        }
+
+        let (name, value) = line
+            .split_once(':')
+            .ok_or_else(|| AppError::Git(format!("invalid header line: {line}")))?;
+        headers.push((name.trim().to_string(), value.trim().to_string()));
+    }
+
+    Ok(GitHttpBackendResponse {
+        status_code,
+        headers,
+        body,
+    })
+}

+ 647 - 0
src/http.rs

@@ -0,0 +1,647 @@
+use std::sync::Arc;
+
+use actix_web::{
+    HttpRequest, HttpResponse, Scope, delete, get, guard, post,
+    web::{Bytes, Data, Json, Path, route},
+};
+use serde::Serialize;
+
+use crate::{
+    app::AppState,
+    error::{AppError, AppResult},
+    models::{
+        AccessMode, AccessTokenResponse, ApiCollaboratorResponse, ApiLoginResponse,
+        ApiPullRequestDetailResponse, ApiPullRequestResponse, ApiRepositoryResponse, ApiUser,
+        Branch, CompareRequest, CompareResponse,
+        CreateAccessTokenRequest, CreateAccessTokenResponse, CreatePullRequestRequest,
+        CreateRepositoryRequest, CreateUserRequest, ForkRepositoryRequest, LoginRequest,
+        MergePullRequestRequest, RepositoryListQuery, UpsertCollaboratorRequest, User,
+    },
+    service,
+};
+
+pub fn build_scope(state: Arc<AppState>) -> Scope {
+    actix_web::web::scope("")
+        .app_data(Data::new(state))
+        .service(healthz)
+        .service(create_user)
+        .service(login)
+        .service(create_access_token)
+        .service(list_access_tokens)
+        .service(delete_access_token)
+        .service(get_user)
+        .service(list_current_user_repositories)
+        .service(list_user_repositories)
+        .service(create_repo)
+        .service(search_repositories)
+        .service(get_repo)
+        .service(fork_repo)
+        .service(list_branches)
+        .service(compare_repositories)
+        .service(create_pull_request)
+        .service(list_pull_requests)
+        .service(get_pull_request)
+        .service(merge_pull_request)
+        .service(close_pull_request)
+        .service(reopen_pull_request)
+        .service(upsert_collaborator)
+        .service(list_collaborators)
+        .service(get_collaborator)
+        .service(delete_collaborator)
+        .route(
+            "/{owner}/{repo}.git/{tail:.*}",
+            route()
+                .guard(guard::Any(guard::Get()).or(guard::Post()))
+                .to(git_http),
+        )
+}
+
+#[get("/healthz")]
+async fn healthz() -> Json<HealthResponse> {
+    Json(HealthResponse { ok: true })
+}
+
+#[post("/api/admin/users")]
+async fn create_user(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    req: Json<CreateUserRequest>,
+) -> AppResult<Json<ApiUser>> {
+    let mut req = req.into_inner();
+    if service::should_allow_bootstrap_admin(state.get_ref().as_ref())? {
+        req.is_admin = true;
+    } else {
+        let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+        if !acting_user.is_admin {
+            return Err(AppError::Forbidden(
+                "only site admin can create users".to_string(),
+            ));
+        }
+    }
+
+    let user = service::create_user(state.get_ref().as_ref(), req)?;
+    Ok(Json(ApiUser::from(&user)))
+}
+
+#[post("/api/user/login")]
+async fn login(
+    state: Data<Arc<AppState>>,
+    req: Json<LoginRequest>,
+) -> AppResult<Json<ApiLoginResponse>> {
+    let login = service::login(state.get_ref().as_ref(), req.into_inner())?;
+    Ok(Json(ApiLoginResponse::from(&login)))
+}
+
+#[post("/api/user/tokens")]
+async fn create_access_token(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    req: Json<CreateAccessTokenRequest>,
+) -> AppResult<Json<CreateAccessTokenResponse>> {
+    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let token = service::issue_access_token(state.get_ref().as_ref(), user.id, req.into_inner())?;
+    Ok(Json(token))
+}
+
+#[get("/api/user/tokens")]
+async fn list_access_tokens(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+) -> AppResult<Json<Vec<AccessTokenResponse>>> {
+    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let tokens = service::list_access_tokens(state.get_ref().as_ref(), user.id)?;
+    Ok(Json(tokens))
+}
+
+#[delete("/api/user/tokens/{id}")]
+async fn delete_access_token(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    token_id: Path<i64>,
+) -> AppResult<HttpResponse> {
+    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    service::delete_access_token(state.get_ref().as_ref(), user.id, token_id.into_inner())?;
+    Ok(HttpResponse::NoContent().finish())
+}
+
+#[get("/api/users/{username}")]
+async fn get_user(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    username: Path<String>,
+) -> AppResult<Json<ApiUser>> {
+    let user = service::get_user(state.get_ref().as_ref(), &username.into_inner())?;
+    let mut api_user = ApiUser::from(&user);
+    if authenticate_request(state.get_ref().as_ref(), &request).is_err() {
+        api_user.email.clear();
+    }
+    Ok(Json(api_user))
+}
+
+#[post("/api/repos")]
+async fn create_repo(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    req: Json<CreateRepositoryRequest>,
+) -> AppResult<Json<ApiRepositoryResponse>> {
+    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let repo = service::create_repository(state.get_ref().as_ref(), &user, req.into_inner())?;
+    Ok(Json(api_repository_response(
+        state.get_ref().as_ref(),
+        Some(&user),
+        &repo,
+    )?))
+}
+
+#[post("/api/repos/{owner}/{repo}/forks")]
+async fn fork_repo(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+    req: Json<ForkRepositoryRequest>,
+) -> AppResult<Json<ApiRepositoryResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo) = path.into_inner();
+    let fork = service::fork_repository(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        req.into_inner(),
+    )?;
+    Ok(Json(api_repository_response(
+        state.get_ref().as_ref(),
+        Some(&acting_user),
+        &fork,
+    )?))
+}
+
+#[get("/api/repos/{owner}/{repo}/branches")]
+async fn list_branches(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+) -> AppResult<Json<Vec<Branch>>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let (owner, repo) = path.into_inner();
+    let branches =
+        service::list_branches(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
+    Ok(Json(branches))
+}
+
+#[get("/api/repos/{owner}/{repo}/compare")]
+async fn compare_repositories(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+    query: actix_web::web::Query<CompareRequest>,
+) -> AppResult<Json<CompareResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo) = path.into_inner();
+    let compare = service::compare_repositories(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        query.into_inner(),
+    )?;
+    Ok(Json(compare))
+}
+
+#[post("/api/repos/{owner}/{repo}/pulls")]
+async fn create_pull_request(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+    req: Json<CreatePullRequestRequest>,
+) -> AppResult<Json<ApiPullRequestResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo) = path.into_inner();
+    let pull_request = service::create_pull_request(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        req.into_inner(),
+    )?;
+    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+}
+
+#[get("/api/repos/{owner}/{repo}/pulls")]
+async fn list_pull_requests(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+) -> AppResult<Json<Vec<ApiPullRequestResponse>>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let (owner, repo) = path.into_inner();
+    let pulls =
+        service::list_pull_requests(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
+    Ok(Json(
+        pulls
+            .iter()
+            .map(ApiPullRequestResponse::from)
+            .collect::<Vec<_>>(),
+    ))
+}
+
+#[get("/api/repos/{owner}/{repo}/pulls/{index}")]
+async fn get_pull_request(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, i64)>,
+) -> AppResult<Json<ApiPullRequestDetailResponse>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let (owner, repo, index) = path.into_inner();
+    let pull_request = service::get_pull_request_detail(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &owner,
+        &repo,
+        index,
+    )?;
+    Ok(Json(ApiPullRequestDetailResponse::from(&pull_request)))
+}
+
+#[post("/api/repos/{owner}/{repo}/pulls/{index}/merge")]
+async fn merge_pull_request(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, i64)>,
+    req: Json<MergePullRequestRequest>,
+) -> AppResult<Json<ApiPullRequestResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo, index) = path.into_inner();
+    let pull_request = service::merge_pull_request(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        index,
+        req.into_inner(),
+    )?;
+    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+}
+
+#[post("/api/repos/{owner}/{repo}/pulls/{index}/close")]
+async fn close_pull_request(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, i64)>,
+) -> AppResult<Json<ApiPullRequestResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo, index) = path.into_inner();
+    let pull_request =
+        service::close_pull_request(state.get_ref().as_ref(), &acting_user, &owner, &repo, index)?;
+    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+}
+
+#[post("/api/repos/{owner}/{repo}/pulls/{index}/reopen")]
+async fn reopen_pull_request(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, i64)>,
+) -> AppResult<Json<ApiPullRequestResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo, index) = path.into_inner();
+    let pull_request =
+        service::reopen_pull_request(state.get_ref().as_ref(), &acting_user, &owner, &repo, index)?;
+    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+}
+
+#[post("/api/repos/{owner}/{repo}/collaborators")]
+async fn upsert_collaborator(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+    req: Json<UpsertCollaboratorRequest>,
+) -> AppResult<Json<ApiCollaboratorResponse>> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo) = path.into_inner();
+    let collaborator = service::upsert_collaborator(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        req.into_inner(),
+    )?;
+    Ok(Json(ApiCollaboratorResponse::from(&collaborator)))
+}
+
+#[actix_web::delete("/api/repos/{owner}/{repo}/collaborators/{username}")]
+async fn delete_collaborator(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, String)>,
+) -> AppResult<HttpResponse> {
+    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let (owner, repo, username) = path.into_inner();
+    service::remove_collaborator(
+        state.get_ref().as_ref(),
+        &acting_user,
+        &owner,
+        &repo,
+        &username,
+    )?;
+    Ok(HttpResponse::NoContent().finish())
+}
+
+async fn git_http(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, String)>,
+    body: Bytes,
+) -> AppResult<HttpResponse> {
+    let (owner_name, repo_name, tail) = path.into_inner();
+    let repo = service::get_repository(state.get_ref().as_ref(), &owner_name, &repo_name)?;
+
+    let query = request.query_string().to_string();
+    let service_name = request
+        .query_string()
+        .split('&')
+        .find_map(|pair| pair.strip_prefix("service="))
+        .unwrap_or_default();
+    let is_pull = service_name == "git-upload-pack"
+        || tail.ends_with("git-upload-pack")
+        || (request.method() == actix_web::http::Method::GET
+            && service_name != "git-receive-pack"
+            && !tail.ends_with("git-receive-pack"));
+
+    let auth_user = if !repo.repo.is_private && is_pull {
+        None
+    } else {
+        Some(authenticate_git_request(
+            state.get_ref().as_ref(),
+            &request,
+        )?)
+    };
+
+    if let Some(user) = &auth_user {
+        let desired = if is_pull {
+            AccessMode::Read
+        } else {
+            AccessMode::Write
+        };
+        if !state.db.authorize(
+            user.id,
+            repo.repo.id,
+            desired,
+            repo.owner.id,
+            repo.repo.is_private,
+        )? {
+            return Err(AppError::Forbidden("repository access denied".to_string()));
+        }
+    } else if repo.repo.is_private || !is_pull {
+        return Err(AppError::Unauthorized(
+            "repository access denied".to_string(),
+        ));
+    }
+
+    let repo_path = crate::repox::repository_path(
+        &state.config.repository.root,
+        &repo.owner.name,
+        &repo.repo.name,
+    );
+    let path_info = format!("/{owner_name}/{repo_name}.git/{tail}");
+    let backend = crate::git::run_git_http_backend(crate::git::GitHttpBackendRequest {
+        git_binary: &state.config.repository.git_binary,
+        project_root: &state.config.repository.root,
+        path_info: &path_info,
+        method: request.method().as_str(),
+        query_string: &query,
+        content_type: request
+            .headers()
+            .get("content-type")
+            .and_then(|v| v.to_str().ok()),
+        remote_user: auth_user.as_ref().map(|u| u.name.as_str()),
+        body: body.as_ref(),
+    })?;
+    if !repo_path.exists() {
+        return Err(AppError::NotFound(format!(
+            "repository not found: {owner_name}/{repo_name}"
+        )));
+    }
+
+    let status = actix_web::http::StatusCode::from_u16(backend.status_code)
+        .map_err(|_| AppError::Git("invalid backend status".to_string()))?;
+    let mut response = HttpResponse::build(status);
+    for (name, value) in backend.headers {
+        response.insert_header((name, value));
+    }
+    Ok(response.body(backend.body))
+}
+
+#[get("/api/repos/{owner}/{repo}")]
+async fn get_repo(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+) -> AppResult<Json<ApiRepositoryResponse>> {
+    let (owner, repo) = path.into_inner();
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let repo = service::get_repository_for_read(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &owner,
+        &repo,
+    )?;
+    Ok(Json(api_repository_response(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &repo,
+    )?))
+}
+
+#[get("/api/user/repos")]
+async fn list_current_user_repositories(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    query: actix_web::web::Query<RepositoryListQuery>,
+) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
+    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let repos =
+        service::list_visible_repositories(state.get_ref().as_ref(), Some(&user), &query.q)?;
+    Ok(Json(api_repository_list(
+        state.get_ref().as_ref(),
+        Some(&user),
+        &repos,
+    )?))
+}
+
+#[get("/api/users/{username}/repos")]
+async fn list_user_repositories(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    username: Path<String>,
+    query: actix_web::web::Query<RepositoryListQuery>,
+) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let repos = service::list_repositories_by_owner(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &username.into_inner(),
+        &query.q,
+    )?;
+    Ok(Json(api_repository_list(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &repos,
+    )?))
+}
+
+#[get("/api/repos/search")]
+async fn search_repositories(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    query: actix_web::web::Query<RepositoryListQuery>,
+) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let repos = service::list_visible_repositories(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &query.q,
+    )?;
+    Ok(Json(api_repository_list(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &repos,
+    )?))
+}
+
+#[get("/api/repos/{owner}/{repo}/collaborators")]
+async fn list_collaborators(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String)>,
+) -> AppResult<Json<Vec<ApiCollaboratorResponse>>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let (owner, repo) = path.into_inner();
+    let collaborators =
+        service::list_collaborators(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
+    Ok(Json(
+        collaborators
+            .iter()
+            .map(ApiCollaboratorResponse::from)
+            .collect(),
+    ))
+}
+
+#[get("/api/repos/{owner}/{repo}/collaborators/{username}")]
+async fn get_collaborator(
+    state: Data<Arc<AppState>>,
+    request: HttpRequest,
+    path: Path<(String, String, String)>,
+) -> AppResult<Json<ApiCollaboratorResponse>> {
+    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let (owner, repo, username) = path.into_inner();
+    let collaborator = service::get_collaborator(
+        state.get_ref().as_ref(),
+        maybe_user.as_ref(),
+        &owner,
+        &repo,
+        &username,
+    )?;
+    Ok(Json(ApiCollaboratorResponse::from(&collaborator)))
+}
+
+#[derive(Serialize)]
+struct HealthResponse {
+    ok: bool,
+}
+
+fn authenticate_request(state: &AppState, request: &HttpRequest) -> AppResult<User> {
+    let header = request
+        .headers()
+        .get("authorization")
+        .ok_or_else(|| AppError::Unauthorized("missing authorization header".to_string()))?;
+    let header = header
+        .to_str()
+        .map_err(|_| AppError::Unauthorized("invalid authorization header".to_string()))?;
+    let token = header
+        .strip_prefix("Bearer ")
+        .ok_or_else(|| AppError::Unauthorized("expected Bearer token".to_string()))?;
+    service::authenticate_token(state, token)
+}
+
+fn authenticate_git_request(state: &AppState, request: &HttpRequest) -> AppResult<User> {
+    let header = request
+        .headers()
+        .get("authorization")
+        .ok_or_else(|| AppError::Unauthorized("missing authorization header".to_string()))?;
+    let header = header
+        .to_str()
+        .map_err(|_| AppError::Unauthorized("invalid authorization header".to_string()))?;
+
+    let encoded = header
+        .strip_prefix("Basic ")
+        .ok_or_else(|| AppError::Unauthorized("expected Basic auth".to_string()))?;
+    let decoded = decode_basic_auth(encoded)?;
+    let (auth_login, secret) = decoded
+        .split_once(':')
+        .ok_or_else(|| AppError::Unauthorized("invalid basic auth payload".to_string()))?;
+    service::authenticate_http_basic(state, auth_login, secret)
+}
+
+fn decode_basic_auth(encoded: &str) -> AppResult<String> {
+    const INVALID: u8 = 255;
+    let mut table = [INVALID; 256];
+    for (idx, ch) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+        .iter()
+        .enumerate()
+    {
+        table[*ch as usize] = idx as u8;
+    }
+
+    let bytes = encoded.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
+    let mut chunk = [0_u8; 4];
+    let mut used = 0;
+    for &b in bytes {
+        if b == b'=' {
+            chunk[used] = 64;
+            used += 1;
+        } else {
+            let value = table[b as usize];
+            if value == INVALID {
+                return Err(AppError::Unauthorized(
+                    "invalid basic auth encoding".to_string(),
+                ));
+            }
+            chunk[used] = value;
+            used += 1;
+        }
+
+        if used == 4 {
+            out.push((chunk[0] << 2) | (chunk[1] >> 4));
+            if chunk[2] != 64 {
+                out.push((chunk[1] << 4) | (chunk[2] >> 2));
+            }
+            if chunk[3] != 64 {
+                out.push((chunk[2] << 6) | chunk[3]);
+            }
+            used = 0;
+        }
+    }
+
+    String::from_utf8(out)
+        .map_err(|_| AppError::Unauthorized("invalid basic auth payload".to_string()))
+}
+
+fn api_repository_response(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repo: &crate::models::RepositoryWithOwner,
+) -> AppResult<ApiRepositoryResponse> {
+    let permission = service::repository_permission(state, requesting_user, repo)?;
+    Ok(ApiRepositoryResponse::from_repo(repo, permission))
+}
+
+fn api_repository_list(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repos: &[crate::models::RepositoryWithOwner],
+) -> AppResult<Vec<ApiRepositoryResponse>> {
+    repos.iter()
+        .map(|repo| api_repository_response(state, requesting_user, repo))
+        .collect()
+}

+ 9 - 0
src/lib.rs

@@ -0,0 +1,9 @@
+pub mod app;
+pub mod conf;
+pub mod db;
+pub mod error;
+pub mod git;
+pub mod http;
+pub mod models;
+pub mod repox;
+pub mod service;

+ 24 - 0
src/main.rs

@@ -0,0 +1,24 @@
+use std::sync::Arc;
+
+use actix_web::{App, HttpServer};
+use gitr::{app::AppState, conf::AppConfig, db::Database, http::build_scope};
+
+#[actix_web::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let config = AppConfig::load()?;
+    config.prepare()?;
+
+    let db = Database::open(&config.database.path)?;
+    db.init_schema()?;
+
+    let state = Arc::new(AppState::new(config, db));
+    let bind_addr = state.config.server.bind.clone();
+    println!("gitr listening on {}", bind_addr);
+
+    HttpServer::new(move || App::new().service(build_scope(state.clone())))
+        .bind(&bind_addr)?
+        .run()
+        .await?;
+
+    Ok(())
+}

+ 396 - 0
src/models.rs

@@ -0,0 +1,396 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[repr(i64)]
+pub enum AccessMode {
+    None = 0,
+    Read = 1,
+    Write = 2,
+    Admin = 3,
+    Owner = 4,
+}
+
+impl AccessMode {
+    pub fn parse_permission(permission: &str) -> Option<Self> {
+        match permission.trim().to_ascii_lowercase().as_str() {
+            "read" => Some(Self::Read),
+            "write" => Some(Self::Write),
+            "admin" => Some(Self::Admin),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[repr(i64)]
+pub enum PullRequestStatus {
+    Conflict = 0,
+    Checking = 1,
+    Mergeable = 2,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct User {
+    pub id: i64,
+    pub lower_name: String,
+    pub name: String,
+    pub full_name: String,
+    pub email: String,
+    pub password_hash: String,
+    pub is_active: bool,
+    pub is_admin: bool,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApiUser {
+    pub id: i64,
+    pub lower_name: String,
+    pub name: String,
+    pub full_name: String,
+    pub email: String,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CreateUserRequest {
+    pub username: String,
+    pub email: String,
+    pub password: String,
+    #[serde(default)]
+    pub full_name: String,
+    #[serde(default)]
+    pub is_admin: bool,
+    #[serde(default = "default_true")]
+    pub is_active: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Repository {
+    pub id: i64,
+    pub owner_id: i64,
+    pub lower_name: String,
+    pub name: String,
+    pub description: String,
+    pub default_branch: String,
+    pub is_private: bool,
+    pub is_bare: bool,
+    pub is_fork: bool,
+    pub fork_id: i64,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CreateRepositoryRequest {
+    pub name: String,
+    #[serde(default)]
+    pub description: String,
+    #[serde(default)]
+    pub is_private: bool,
+    #[serde(default)]
+    pub auto_init: bool,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ForkRepositoryRequest {
+    pub name: String,
+    #[serde(default)]
+    pub description: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RepositoryWithOwner {
+    pub repo: Repository,
+    pub owner: User,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApiRepositoryWithOwner {
+    pub repo: Repository,
+    pub owner: ApiUser,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RepositoryPermission {
+    pub mode: AccessMode,
+    pub can_read: bool,
+    pub can_write: bool,
+    pub can_admin: bool,
+    pub is_owner: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApiRepositoryResponse {
+    pub repo: Repository,
+    pub owner: ApiUser,
+    pub permission: RepositoryPermission,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AccessToken {
+    pub id: i64,
+    pub user_id: i64,
+    pub name: String,
+    pub token_hash: String,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Collaboration {
+    pub id: i64,
+    pub user_id: i64,
+    pub repo_id: i64,
+    pub mode: AccessMode,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct LoginRequest {
+    pub login: String,
+    pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LoginResponse {
+    pub token: String,
+    pub user: User,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ApiLoginResponse {
+    pub token: String,
+    pub user: ApiUser,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CreateAccessTokenRequest {
+    pub name: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CreateAccessTokenResponse {
+    pub id: i64,
+    pub name: String,
+    pub token: String,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AccessTokenResponse {
+    pub id: i64,
+    pub name: String,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct UpsertCollaboratorRequest {
+    pub username: String,
+    pub permission: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CollaboratorResponse {
+    pub user: User,
+    pub mode: AccessMode,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ApiCollaboratorResponse {
+    pub user: ApiUser,
+    pub mode: AccessMode,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Branch {
+    pub name: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PullRequest {
+    pub id: i64,
+    pub index: i64,
+    pub title: String,
+    pub body: String,
+    pub status: PullRequestStatus,
+    pub head_repo_id: i64,
+    pub base_repo_id: i64,
+    pub head_user_name: String,
+    pub head_branch: String,
+    pub base_branch: String,
+    pub merge_base: String,
+    pub merged_commit_id: String,
+    pub poster_id: i64,
+    pub has_merged: bool,
+    pub is_closed: bool,
+    pub created_unix: i64,
+    pub updated_unix: i64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CreatePullRequestRequest {
+    pub head_owner: String,
+    pub head_repo: String,
+    pub head_branch: String,
+    pub base_branch: String,
+    pub title: String,
+    #[serde(default)]
+    pub body: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct MergePullRequestRequest {
+    #[serde(default)]
+    pub message: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CompareRequest {
+    pub base: String,
+    pub head_owner: String,
+    pub head_repo: String,
+    pub head_branch: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct RepositoryListQuery {
+    #[serde(default)]
+    pub q: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CompareCommit {
+    pub id: String,
+    pub summary: String,
+    pub author_name: String,
+    pub author_email: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CompareFile {
+    pub path: String,
+    pub additions: i64,
+    pub deletions: i64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CompareResponse {
+    pub base_branch: String,
+    pub head_branch: String,
+    pub merge_base: String,
+    pub head_commit_id: String,
+    pub status: PullRequestStatus,
+    pub commits: Vec<CompareCommit>,
+    pub files: Vec<CompareFile>,
+    pub is_nothing_to_compare: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PullRequestResponse {
+    pub pull_request: PullRequest,
+    pub head_repo: RepositoryWithOwner,
+    pub base_repo: RepositoryWithOwner,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApiPullRequestResponse {
+    pub pull_request: PullRequest,
+    pub head_repo: ApiRepositoryWithOwner,
+    pub base_repo: ApiRepositoryWithOwner,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PullRequestDetailResponse {
+    pub pull_request: PullRequest,
+    pub head_repo: RepositoryWithOwner,
+    pub base_repo: RepositoryWithOwner,
+    pub compare: CompareResponse,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApiPullRequestDetailResponse {
+    pub pull_request: PullRequest,
+    pub head_repo: ApiRepositoryWithOwner,
+    pub base_repo: ApiRepositoryWithOwner,
+    pub compare: CompareResponse,
+}
+
+fn default_true() -> bool {
+    true
+}
+
+impl From<&User> for ApiUser {
+    fn from(value: &User) -> Self {
+        Self {
+            id: value.id,
+            lower_name: value.lower_name.clone(),
+            name: value.name.clone(),
+            full_name: value.full_name.clone(),
+            email: value.email.clone(),
+            created_unix: value.created_unix,
+            updated_unix: value.updated_unix,
+        }
+    }
+}
+
+impl From<&RepositoryWithOwner> for ApiRepositoryWithOwner {
+    fn from(value: &RepositoryWithOwner) -> Self {
+        Self {
+            repo: value.repo.clone(),
+            owner: ApiUser::from(&value.owner),
+        }
+    }
+}
+
+impl ApiRepositoryResponse {
+    pub fn from_repo(value: &RepositoryWithOwner, permission: RepositoryPermission) -> Self {
+        Self {
+            repo: value.repo.clone(),
+            owner: ApiUser::from(&value.owner),
+            permission,
+        }
+    }
+}
+
+impl From<&LoginResponse> for ApiLoginResponse {
+    fn from(value: &LoginResponse) -> Self {
+        Self {
+            token: value.token.clone(),
+            user: ApiUser::from(&value.user),
+        }
+    }
+}
+
+impl From<&CollaboratorResponse> for ApiCollaboratorResponse {
+    fn from(value: &CollaboratorResponse) -> Self {
+        Self {
+            user: ApiUser::from(&value.user),
+            mode: value.mode,
+        }
+    }
+}
+
+impl From<&PullRequestResponse> for ApiPullRequestResponse {
+    fn from(value: &PullRequestResponse) -> Self {
+        Self {
+            pull_request: value.pull_request.clone(),
+            head_repo: ApiRepositoryWithOwner::from(&value.head_repo),
+            base_repo: ApiRepositoryWithOwner::from(&value.base_repo),
+        }
+    }
+}
+
+impl From<&PullRequestDetailResponse> for ApiPullRequestDetailResponse {
+    fn from(value: &PullRequestDetailResponse) -> Self {
+        Self {
+            pull_request: value.pull_request.clone(),
+            head_repo: ApiRepositoryWithOwner::from(&value.head_repo),
+            base_repo: ApiRepositoryWithOwner::from(&value.base_repo),
+            compare: value.compare.clone(),
+        }
+    }
+}

+ 9 - 0
src/repox.rs

@@ -0,0 +1,9 @@
+use std::path::{Path, PathBuf};
+
+pub fn user_path(root: &Path, user: &str) -> PathBuf {
+    root.join(user.to_ascii_lowercase())
+}
+
+pub fn repository_path(root: &Path, owner: &str, repo: &str) -> PathBuf {
+    user_path(root, owner).join(format!("{}.git", repo.to_ascii_lowercase()))
+}

+ 1163 - 0
src/service.rs

@@ -0,0 +1,1163 @@
+use argon2::{
+    Argon2, PasswordHash, PasswordVerifier,
+    password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
+};
+use rand_core::RngCore;
+use sha2::{Digest, Sha256};
+
+use crate::{
+    app::AppState,
+    db::{NewRepository, NewUser},
+    error::{AppError, AppResult},
+    git,
+    models::{
+        AccessMode, AccessTokenResponse, Branch, CollaboratorResponse, CompareCommit,
+        CompareFile, CompareRequest, CompareResponse, CreateAccessTokenRequest,
+        CreateAccessTokenResponse, CreatePullRequestRequest, CreateRepositoryRequest,
+        CreateUserRequest, ForkRepositoryRequest, LoginRequest, LoginResponse,
+        MergePullRequestRequest, PullRequest, PullRequestDetailResponse, PullRequestResponse,
+        PullRequestStatus, RepositoryPermission, RepositoryWithOwner, UpsertCollaboratorRequest,
+        User,
+    },
+    repox,
+};
+
+pub fn create_user(state: &AppState, req: CreateUserRequest) -> AppResult<User> {
+    validate_user_name(&req.username)?;
+    validate_email(&req.email)?;
+    if req.password.len() < 8 {
+        return Err(AppError::Validation(
+            "password must be at least 8 characters".to_string(),
+        ));
+    }
+
+    let salt = SaltString::generate(&mut OsRng);
+    let password_hash = Argon2::default()
+        .hash_password(req.password.as_bytes(), &salt)
+        .map_err(|err| AppError::Validation(format!("password hashing failed: {err}")))?
+        .to_string();
+
+    state.db.create_user(NewUser {
+        username: &req.username,
+        full_name: &req.full_name,
+        email: &req.email,
+        password_hash: &password_hash,
+        is_active: req.is_active,
+        is_admin: req.is_admin,
+    })
+}
+
+pub fn get_user(state: &AppState, username: &str) -> AppResult<User> {
+    state
+        .db
+        .get_user_by_username(username)?
+        .ok_or_else(|| AppError::NotFound(format!("user not found: {username}")))
+}
+
+pub fn should_allow_bootstrap_admin(state: &AppState) -> AppResult<bool> {
+    Ok(state.db.user_count()? == 0)
+}
+
+pub fn login(state: &AppState, req: LoginRequest) -> AppResult<LoginResponse> {
+    let user = if req.login.contains('@') {
+        state
+            .db
+            .get_user_by_email(&req.login)?
+            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
+    } else {
+        state
+            .db
+            .get_user_by_username(&req.login)?
+            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
+    };
+
+    if !user.is_active {
+        return Err(AppError::Unauthorized("inactive user".to_string()));
+    }
+
+    let parsed_hash = PasswordHash::new(&user.password_hash)
+        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
+    Argon2::default()
+        .verify_password(req.password.as_bytes(), &parsed_hash)
+        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
+
+    let token = issue_access_token(
+        state,
+        user.id,
+        CreateAccessTokenRequest {
+            name: "login".to_string(),
+        },
+    )?;
+
+    Ok(LoginResponse {
+        token: token.token,
+        user,
+    })
+}
+
+pub fn issue_access_token(
+    state: &AppState,
+    user_id: i64,
+    req: CreateAccessTokenRequest,
+) -> AppResult<CreateAccessTokenResponse> {
+    if req.name.trim().is_empty() {
+        return Err(AppError::Validation(
+            "token name cannot be empty".to_string(),
+        ));
+    }
+
+    let token = random_token();
+    let token_hash = hash_token(&token);
+    let record = state
+        .db
+        .create_access_token(user_id, req.name.trim(), &token_hash)?;
+
+    Ok(CreateAccessTokenResponse {
+        id: record.id,
+        name: record.name,
+        token,
+        created_unix: record.created_unix,
+        updated_unix: record.updated_unix,
+    })
+}
+
+pub fn list_access_tokens(state: &AppState, user_id: i64) -> AppResult<Vec<AccessTokenResponse>> {
+    let tokens = state.db.list_access_tokens_by_user(user_id)?;
+    Ok(tokens
+        .into_iter()
+        .map(|token| AccessTokenResponse {
+            id: token.id,
+            name: token.name,
+            created_unix: token.created_unix,
+            updated_unix: token.updated_unix,
+        })
+        .collect())
+}
+
+pub fn delete_access_token(state: &AppState, user_id: i64, token_id: i64) -> AppResult<()> {
+    if state.db.delete_access_token_by_id(user_id, token_id)? {
+        return Ok(());
+    }
+    Err(AppError::NotFound(format!(
+        "access token not found: {token_id}"
+    )))
+}
+
+pub fn authenticate_token(state: &AppState, bearer_token: &str) -> AppResult<User> {
+    let token_hash = hash_token(bearer_token);
+    let token = state
+        .db
+        .get_access_token_by_hash(&token_hash)?
+        .ok_or_else(|| AppError::Unauthorized("invalid access token".to_string()))?;
+    let _ = state.db.touch_access_token(token.id);
+    let user = state
+        .db
+        .get_user_by_id(token.user_id)?
+        .ok_or_else(|| AppError::Unauthorized("token owner not found".to_string()))?;
+
+    if !user.is_active {
+        return Err(AppError::Unauthorized("inactive user".to_string()));
+    }
+
+    Ok(user)
+}
+
+pub fn authenticate_http_basic(state: &AppState, login: &str, secret: &str) -> AppResult<User> {
+    match login_with_password(state, login, secret) {
+        Ok(user) => Ok(user),
+        Err(AppError::Unauthorized(_)) => {
+            authenticate_token(state, secret).or_else(|_| authenticate_token(state, login))
+        }
+        Err(err) => Err(err),
+    }
+}
+
+pub fn create_repository(
+    state: &AppState,
+    owner: &User,
+    req: CreateRepositoryRequest,
+) -> AppResult<RepositoryWithOwner> {
+    validate_repo_name(&req.name)?;
+
+    let repo_path = repox::repository_path(&state.config.repository.root, &owner.name, &req.name);
+    if repo_path.exists() {
+        return Err(AppError::Conflict(format!(
+            "repository directory already exists: {}",
+            repo_path.display()
+        )));
+    }
+
+    let repo = state.db.create_repository(NewRepository {
+        owner_id: owner.id,
+        owner_name: &owner.name,
+        name: &req.name,
+        description: &req.description,
+        default_branch: &state.config.repository.default_branch,
+        is_private: req.is_private,
+        is_bare: !req.auto_init,
+        is_fork: false,
+        fork_id: 0,
+    })?;
+
+    if let Err(err) = git::init_bare_repo_with_binary(
+        &state.config.repository.git_binary,
+        &repo_path,
+        &state.config.repository.default_branch,
+    ) {
+        let _ = std::fs::remove_dir_all(&repo_path);
+        let _ = state.db.delete_repository_by_id(repo.id);
+        return Err(err);
+    }
+
+    if req.auto_init {
+        let readme = format!("# {}\n", req.name);
+        if let Err(err) = git::create_initial_commit_with_binary(
+            &state.config.repository.git_binary,
+            &repo_path,
+            &state.config.repository.default_branch,
+            &readme,
+            &owner.name,
+            &owner.email,
+        ) {
+            let _ = std::fs::remove_dir_all(&repo_path);
+            let _ = state.db.delete_repository_by_id(repo.id);
+            return Err(err);
+        }
+    }
+
+    Ok(RepositoryWithOwner {
+        repo,
+        owner: owner.clone(),
+    })
+}
+
+pub fn fork_repository(
+    state: &AppState,
+    acting_user: &User,
+    base_owner_name: &str,
+    base_repo_name: &str,
+    req: ForkRepositoryRequest,
+) -> AppResult<RepositoryWithOwner> {
+    let base_repo = get_repository(state, base_owner_name, base_repo_name)?;
+    ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?;
+    if acting_user.id == base_repo.owner.id {
+        return Err(AppError::Validation(
+            "cannot fork to the same owner".to_string(),
+        ));
+    }
+    if state.db.has_forked_by(base_repo.repo.id, acting_user.id)? {
+        return Err(AppError::Conflict(
+            "repository already forked by this user".to_string(),
+        ));
+    }
+    validate_repo_name(&req.name)?;
+
+    let repo = state.db.create_repository(NewRepository {
+        owner_id: acting_user.id,
+        owner_name: &acting_user.name,
+        name: &req.name,
+        description: &req.description,
+        default_branch: &base_repo.repo.default_branch,
+        is_private: base_repo.repo.is_private,
+        is_bare: true,
+        is_fork: true,
+        fork_id: base_repo.repo.id,
+    })?;
+
+    let source_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &base_repo.owner.name,
+        &base_repo.repo.name,
+    );
+    let target_repo_path =
+        repox::repository_path(&state.config.repository.root, &acting_user.name, &req.name);
+    if let Err(err) = git::clone_bare_repo_with_binary(
+        &state.config.repository.git_binary,
+        &source_repo_path,
+        &target_repo_path,
+    ) {
+        let _ = std::fs::remove_dir_all(&target_repo_path);
+        let _ = state.db.delete_repository_by_id(repo.id);
+        return Err(err);
+    }
+
+    Ok(RepositoryWithOwner {
+        repo,
+        owner: acting_user.clone(),
+    })
+}
+
+pub fn upsert_collaborator(
+    state: &AppState,
+    acting_user: &User,
+    owner_name: &str,
+    repo_name: &str,
+    req: UpsertCollaboratorRequest,
+) -> AppResult<CollaboratorResponse> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_owner(acting_user, &repo)?;
+
+    let collaborator = get_user(state, &req.username)?;
+    if collaborator.id == repo.owner.id {
+        return Err(AppError::Validation(
+            "repository owner cannot be added as collaborator".to_string(),
+        ));
+    }
+
+    let mode = AccessMode::parse_permission(&req.permission).ok_or_else(|| {
+        AppError::Validation("permission must be one of: read, write, admin".to_string())
+    })?;
+    state
+        .db
+        .upsert_collaboration(repo.repo.id, collaborator.id, mode)?;
+
+    Ok(CollaboratorResponse {
+        user: collaborator,
+        mode,
+    })
+}
+
+pub fn remove_collaborator(
+    state: &AppState,
+    acting_user: &User,
+    owner_name: &str,
+    repo_name: &str,
+    collaborator_name: &str,
+) -> AppResult<()> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_owner(acting_user, &repo)?;
+    let collaborator = get_user(state, collaborator_name)?;
+    state.db.delete_collaboration(repo.repo.id, collaborator.id)
+}
+
+pub fn list_collaborators(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+) -> AppResult<Vec<CollaboratorResponse>> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_readable(state, requesting_user, &repo)?;
+    state.db.list_collaborators(repo.repo.id)
+}
+
+pub fn get_collaborator(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+    collaborator_name: &str,
+) -> AppResult<CollaboratorResponse> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_readable(state, requesting_user, &repo)?;
+    let collaborator = get_user(state, collaborator_name)?;
+    state
+        .db
+        .get_collaborator(repo.repo.id, collaborator.id)?
+        .ok_or_else(|| AppError::NotFound(format!("collaborator not found: {collaborator_name}")))
+}
+
+pub fn list_repositories_by_owner(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    query: &str,
+) -> AppResult<Vec<RepositoryWithOwner>> {
+    let owner = get_user(state, owner_name)?;
+    let repos = state.db.list_repositories_with_owners_by_owner(owner.id)?;
+    filter_visible_repositories(state, requesting_user, repos, query)
+}
+
+pub fn list_visible_repositories(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    query: &str,
+) -> AppResult<Vec<RepositoryWithOwner>> {
+    let repos = state.db.list_repositories_with_owners()?;
+    filter_visible_repositories(state, requesting_user, repos, query)
+}
+
+pub fn repository_permission(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repo: &RepositoryWithOwner,
+) -> AppResult<RepositoryPermission> {
+    let mode = effective_access_mode(state, requesting_user, repo)?;
+    Ok(RepositoryPermission {
+        mode,
+        can_read: (mode as i64) >= (AccessMode::Read as i64),
+        can_write: (mode as i64) >= (AccessMode::Write as i64),
+        can_admin: (mode as i64) >= (AccessMode::Admin as i64),
+        is_owner: mode == AccessMode::Owner,
+    })
+}
+
+pub fn list_branches(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+) -> AppResult<Vec<Branch>> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_access(state, requesting_user, &repo, AccessMode::Read)?;
+
+    let repo_path = repox::repository_path(&state.config.repository.root, owner_name, repo_name);
+    let branches = git::list_branches_with_binary(&state.config.repository.git_binary, &repo_path)?;
+    Ok(branches.into_iter().map(|name| Branch { name }).collect())
+}
+
+pub fn create_pull_request(
+    state: &AppState,
+    acting_user: &User,
+    base_owner_name: &str,
+    base_repo_name: &str,
+    req: CreatePullRequestRequest,
+) -> AppResult<PullRequestResponse> {
+    if req.title.trim().is_empty() {
+        return Err(AppError::Validation(
+            "pull request title cannot be empty".to_string(),
+        ));
+    }
+
+    let base_repo = get_repository(state, base_owner_name, base_repo_name)?;
+    ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?;
+
+    let base_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &base_repo.owner.name,
+        &base_repo.repo.name,
+    );
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &req.base_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "base branch not found: {}",
+            req.base_branch
+        )));
+    }
+
+    let head_repo = get_repository(state, &req.head_owner, &req.head_repo)?;
+    let same_repo = head_repo.repo.id == base_repo.repo.id;
+    if same_repo && req.head_branch == req.base_branch {
+        return Err(AppError::Validation(
+            "head and base branch cannot be the same".to_string(),
+        ));
+    }
+    if !same_repo && (!head_repo.repo.is_fork || head_repo.repo.fork_id != base_repo.repo.id) {
+        return Err(AppError::NotFound(
+            "head repository is not a fork of base repository".to_string(),
+        ));
+    }
+
+    ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Write)?;
+
+    let head_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &head_repo.owner.name,
+        &head_repo.repo.name,
+    );
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &head_repo_path,
+        &req.head_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "head branch not found: {}",
+            req.head_branch
+        )));
+    }
+
+    if state
+        .db
+        .get_unmerged_pull_request(
+            head_repo.repo.id,
+            base_repo.repo.id,
+            &req.head_branch,
+            &req.base_branch,
+        )?
+        .is_some()
+    {
+        return Err(AppError::Conflict(
+            "an open pull request already exists for this head/base branch pair".to_string(),
+        ));
+    }
+
+    let merge_base = git::merge_base_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &req.base_branch,
+        &head_repo_path,
+        &req.head_branch,
+    )?;
+    if merge_base.is_empty() {
+        return Err(AppError::Validation(
+            "no merge base between branches".to_string(),
+        ));
+    }
+    let status = if git::test_merge_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &req.base_branch,
+        &head_repo_path,
+        &req.head_branch,
+    )? {
+        PullRequestStatus::Mergeable
+    } else {
+        PullRequestStatus::Conflict
+    };
+
+    let pull_request = state.db.create_pull_request(crate::db::NewPullRequest {
+        title: req.title.trim(),
+        body: req.body.trim(),
+        status,
+        head_repo_id: head_repo.repo.id,
+        base_repo_id: base_repo.repo.id,
+        head_user_name: head_repo.owner.name.as_str(),
+        head_branch: req.head_branch.as_str(),
+        base_branch: req.base_branch.as_str(),
+        merge_base: merge_base.as_str(),
+        poster_id: acting_user.id,
+    })?;
+
+    Ok(PullRequestResponse {
+        pull_request,
+        head_repo,
+        base_repo,
+    })
+}
+
+pub fn merge_pull_request(
+    state: &AppState,
+    acting_user: &User,
+    base_owner_name: &str,
+    base_repo_name: &str,
+    index: i64,
+    req: MergePullRequestRequest,
+) -> AppResult<PullRequestResponse> {
+    let base_repo = get_repository(state, base_owner_name, base_repo_name)?;
+    ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Write)?;
+
+    let pull = state
+        .db
+        .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)?
+        .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?;
+    if pull.has_merged || pull.is_closed {
+        return Err(AppError::Conflict(
+            "pull request is already closed".to_string(),
+        ));
+    }
+    if pull.status != PullRequestStatus::Mergeable {
+        return Err(AppError::Conflict(
+            "pull request cannot be merged automatically".to_string(),
+        ));
+    }
+
+    let head_repo = get_repository_by_id(state, pull.head_repo_id)?;
+    let base_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &base_repo.owner.name,
+        &base_repo.repo.name,
+    );
+    let head_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &head_repo.owner.name,
+        &head_repo.repo.name,
+    );
+    let message = if req.message.trim().is_empty() {
+        format!(
+            "Merge branch '{}' of {}/{} into {}",
+            pull.head_branch, head_repo.owner.name, head_repo.repo.name, pull.base_branch
+        )
+    } else {
+        req.message.trim().to_string()
+    };
+
+    let merged_commit_id = git::merge_pull_request_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &pull.base_branch,
+        &head_repo_path,
+        &pull.head_branch,
+        &acting_user.name,
+        &acting_user.email,
+        &message,
+    )?;
+    let pull_request = state
+        .db
+        .mark_pull_request_merged(pull.id, &merged_commit_id)?;
+    build_pull_request_response(state, pull_request, Some(head_repo), Some(base_repo))
+}
+
+pub fn close_pull_request(
+    state: &AppState,
+    acting_user: &User,
+    base_owner_name: &str,
+    base_repo_name: &str,
+    index: i64,
+) -> AppResult<PullRequestResponse> {
+    let base_repo = get_repository(state, base_owner_name, base_repo_name)?;
+    let pull = state
+        .db
+        .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)?
+        .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?;
+    ensure_pull_request_admin(state, acting_user, &base_repo, &pull)?;
+    if pull.has_merged {
+        return Err(AppError::Conflict(
+            "merged pull request cannot be closed".to_string(),
+        ));
+    }
+    if pull.is_closed {
+        return Err(AppError::Conflict(
+            "pull request is already closed".to_string(),
+        ));
+    }
+
+    let pull_request = state
+        .db
+        .update_pull_request_open_state(pull.id, true, pull.status)?;
+    build_pull_request_response(state, pull_request, None, Some(base_repo))
+}
+
+pub fn reopen_pull_request(
+    state: &AppState,
+    acting_user: &User,
+    base_owner_name: &str,
+    base_repo_name: &str,
+    index: i64,
+) -> AppResult<PullRequestResponse> {
+    let base_repo = get_repository(state, base_owner_name, base_repo_name)?;
+    let pull = state
+        .db
+        .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)?
+        .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?;
+    ensure_pull_request_admin(state, acting_user, &base_repo, &pull)?;
+    if pull.has_merged {
+        return Err(AppError::Conflict(
+            "merged pull request cannot be reopened".to_string(),
+        ));
+    }
+    if !pull.is_closed {
+        return Err(AppError::Conflict(
+            "pull request is already open".to_string(),
+        ));
+    }
+
+    if let Some(existing) = state.db.get_unmerged_pull_request(
+        pull.head_repo_id,
+        pull.base_repo_id,
+        &pull.head_branch,
+        &pull.base_branch,
+    )? {
+        if existing.id != pull.id {
+            return Err(AppError::Conflict(
+                "an open pull request already exists for this head/base branch pair".to_string(),
+            ));
+        }
+    }
+
+    let head_repo = get_repository_by_id(state, pull.head_repo_id)?;
+    let base_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &base_repo.owner.name,
+        &base_repo.repo.name,
+    );
+    let head_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &head_repo.owner.name,
+        &head_repo.repo.name,
+    );
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &pull.base_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "base branch not found: {}",
+            pull.base_branch
+        )));
+    }
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &head_repo_path,
+        &pull.head_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "head branch not found: {}",
+            pull.head_branch
+        )));
+    }
+    let merge_base = git::merge_base_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &pull.base_branch,
+        &head_repo_path,
+        &pull.head_branch,
+    )?;
+    if merge_base.is_empty() {
+        return Err(AppError::Validation(
+            "no merge base between branches".to_string(),
+        ));
+    }
+    let status = if git::test_merge_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        &pull.base_branch,
+        &head_repo_path,
+        &pull.head_branch,
+    )? {
+        PullRequestStatus::Mergeable
+    } else {
+        PullRequestStatus::Conflict
+    };
+
+    let pull_request = state
+        .db
+        .update_pull_request_open_state(pull.id, false, status)?;
+    build_pull_request_response(state, pull_request, Some(head_repo), Some(base_repo))
+}
+
+pub fn list_pull_requests(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+) -> AppResult<Vec<PullRequestResponse>> {
+    let base_repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_access(state, requesting_user, &base_repo, AccessMode::Read)?;
+
+    let pulls = state
+        .db
+        .list_pull_requests_by_base_repo(base_repo.repo.id)?;
+    let mut result = Vec::with_capacity(pulls.len());
+    for pull in pulls {
+        result.push(build_pull_request_response(
+            state,
+            pull,
+            None,
+            Some(base_repo.clone()),
+        )?);
+    }
+    Ok(result)
+}
+
+pub fn get_pull_request_detail(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+    index: i64,
+) -> AppResult<PullRequestDetailResponse> {
+    let base_repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_readable(state, requesting_user, &base_repo)?;
+    let pull_request = state
+        .db
+        .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)?
+        .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?;
+    let head_repo = get_repository_by_id(state, pull_request.head_repo_id)?;
+    let compare = build_compare_for_pull_request(state, &base_repo, &head_repo, &pull_request)?;
+    Ok(PullRequestDetailResponse {
+        pull_request,
+        head_repo,
+        base_repo,
+        compare,
+    })
+}
+
+pub fn compare_repositories(
+    state: &AppState,
+    acting_user: &User,
+    owner_name: &str,
+    repo_name: &str,
+    req: CompareRequest,
+) -> AppResult<CompareResponse> {
+    let base_repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?;
+
+    let head_repo = get_repository(state, &req.head_owner, &req.head_repo)?;
+    let same_repo = head_repo.repo.id == base_repo.repo.id;
+    if !same_repo && (!head_repo.repo.is_fork || head_repo.repo.fork_id != base_repo.repo.id) {
+        return Err(AppError::NotFound(
+            "head repository is not a fork of base repository".to_string(),
+        ));
+    }
+    ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Write)?;
+
+    build_compare_for_refs(
+        state,
+        &base_repo,
+        &head_repo,
+        req.base.trim(),
+        req.head_branch.trim(),
+    )
+}
+
+pub fn get_repository(
+    state: &AppState,
+    owner_name: &str,
+    repo_name: &str,
+) -> AppResult<RepositoryWithOwner> {
+    let owner = get_user(state, owner_name)?;
+    let repo = state
+        .db
+        .get_repository_by_name(owner.id, repo_name)?
+        .ok_or_else(|| {
+            AppError::NotFound(format!("repository not found: {owner_name}/{repo_name}"))
+        })?;
+    Ok(RepositoryWithOwner { repo, owner })
+}
+
+pub fn get_repository_by_id(state: &AppState, repo_id: i64) -> AppResult<RepositoryWithOwner> {
+    let repo = state
+        .db
+        .get_repository_by_id(repo_id)?
+        .ok_or_else(|| AppError::NotFound(format!("repository not found: {repo_id}")))?;
+    let owner = state.db.get_user_by_id(repo.owner_id)?.ok_or_else(|| {
+        AppError::NotFound(format!("repository owner not found: {}", repo.owner_id))
+    })?;
+    Ok(RepositoryWithOwner { repo, owner })
+}
+
+pub fn get_repository_for_read(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    owner_name: &str,
+    repo_name: &str,
+) -> AppResult<RepositoryWithOwner> {
+    let repo = get_repository(state, owner_name, repo_name)?;
+    ensure_repo_readable(state, requesting_user, &repo)?;
+    Ok(repo)
+}
+
+fn build_pull_request_response(
+    state: &AppState,
+    pull_request: PullRequest,
+    head_repo: Option<RepositoryWithOwner>,
+    base_repo: Option<RepositoryWithOwner>,
+) -> AppResult<PullRequestResponse> {
+    let head_repo = match head_repo {
+        Some(repo) => repo,
+        None => get_repository_by_id(state, pull_request.head_repo_id)?,
+    };
+    let base_repo = match base_repo {
+        Some(repo) => repo,
+        None => get_repository_by_id(state, pull_request.base_repo_id)?,
+    };
+    Ok(PullRequestResponse {
+        pull_request,
+        head_repo,
+        base_repo,
+    })
+}
+
+fn build_compare_for_pull_request(
+    state: &AppState,
+    base_repo: &RepositoryWithOwner,
+    head_repo: &RepositoryWithOwner,
+    pull_request: &PullRequest,
+) -> AppResult<CompareResponse> {
+    if pull_request.has_merged && !pull_request.merged_commit_id.is_empty() {
+        let base_repo_path = repox::repository_path(
+            &state.config.repository.root,
+            &base_repo.owner.name,
+            &base_repo.repo.name,
+        );
+        let result = git::compare_rev_range_with_binary(
+            &state.config.repository.git_binary,
+            &base_repo_path,
+            &pull_request.merge_base,
+            &pull_request.merged_commit_id,
+        )?;
+        Ok(map_compare_result(
+            pull_request.base_branch.as_str(),
+            pull_request.head_branch.as_str(),
+            if result.files.is_empty() && result.commits.is_empty() {
+                PullRequestStatus::Mergeable
+            } else {
+                pull_request.status
+            },
+            result,
+        ))
+    } else {
+        build_compare_for_refs(
+            state,
+            base_repo,
+            head_repo,
+            &pull_request.base_branch,
+            &pull_request.head_branch,
+        )
+    }
+}
+
+fn build_compare_for_refs(
+    state: &AppState,
+    base_repo: &RepositoryWithOwner,
+    head_repo: &RepositoryWithOwner,
+    base_branch: &str,
+    head_branch: &str,
+) -> AppResult<CompareResponse> {
+    let base_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &base_repo.owner.name,
+        &base_repo.repo.name,
+    );
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        base_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "base branch not found: {base_branch}"
+        )));
+    }
+
+    let head_repo_path = repox::repository_path(
+        &state.config.repository.root,
+        &head_repo.owner.name,
+        &head_repo.repo.name,
+    );
+    if !git::branch_exists_with_binary(
+        &state.config.repository.git_binary,
+        &head_repo_path,
+        head_branch,
+    )? {
+        return Err(AppError::NotFound(format!(
+            "head branch not found: {head_branch}"
+        )));
+    }
+
+    let result = git::compare_refs_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        base_branch,
+        &head_repo_path,
+        head_branch,
+    )?;
+    let status = if result.merge_base == result.head_commit_id {
+        PullRequestStatus::Mergeable
+    } else if git::test_merge_with_binary(
+        &state.config.repository.git_binary,
+        &base_repo_path,
+        base_branch,
+        &head_repo_path,
+        head_branch,
+    )? {
+        PullRequestStatus::Mergeable
+    } else {
+        PullRequestStatus::Conflict
+    };
+    Ok(map_compare_result(base_branch, head_branch, status, result))
+}
+
+fn map_compare_result(
+    base_branch: &str,
+    head_branch: &str,
+    status: PullRequestStatus,
+    result: git::CompareResult,
+) -> CompareResponse {
+    let is_nothing_to_compare = result.merge_base == result.head_commit_id;
+    CompareResponse {
+        base_branch: base_branch.to_string(),
+        head_branch: head_branch.to_string(),
+        merge_base: result.merge_base,
+        head_commit_id: result.head_commit_id,
+        status,
+        commits: result
+            .commits
+            .into_iter()
+            .map(|commit| CompareCommit {
+                id: commit.id,
+                summary: commit.summary,
+                author_name: commit.author_name,
+                author_email: commit.author_email,
+            })
+            .collect(),
+        files: result
+            .files
+            .into_iter()
+            .map(|file| CompareFile {
+                path: file.path,
+                additions: file.additions,
+                deletions: file.deletions,
+            })
+            .collect(),
+        is_nothing_to_compare,
+    }
+}
+
+fn ensure_repo_readable(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repo: &RepositoryWithOwner,
+) -> AppResult<()> {
+    ensure_repo_access(state, requesting_user, repo, AccessMode::Read)
+}
+
+fn ensure_repo_access(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repo: &RepositoryWithOwner,
+    desired: AccessMode,
+) -> AppResult<()> {
+    let mode = effective_access_mode(state, requesting_user, repo)?;
+    if (mode as i64) >= (desired as i64) {
+        return Ok(());
+    }
+    if repo.repo.is_private {
+        return Err(AppError::NotFound(format!(
+            "repository not found: {}/{}",
+            repo.owner.name, repo.repo.name
+        )));
+    }
+    Err(AppError::Forbidden("repository access denied".to_string()))
+}
+
+fn ensure_pull_request_admin(
+    state: &AppState,
+    acting_user: &User,
+    base_repo: &RepositoryWithOwner,
+    pull: &PullRequest,
+) -> AppResult<()> {
+    let is_writer = state.db.authorize(
+        acting_user.id,
+        base_repo.repo.id,
+        AccessMode::Write,
+        base_repo.owner.id,
+        base_repo.repo.is_private,
+    )?;
+    if is_writer || acting_user.id == pull.poster_id {
+        return Ok(());
+    }
+
+    Err(AppError::Forbidden(
+        "pull request status change denied".to_string(),
+    ))
+}
+
+pub fn login_with_password(state: &AppState, login: &str, password: &str) -> AppResult<User> {
+    let user = if login.contains('@') {
+        state
+            .db
+            .get_user_by_email(login)?
+            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
+    } else {
+        state
+            .db
+            .get_user_by_username(login)?
+            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
+    };
+
+    if !user.is_active {
+        return Err(AppError::Unauthorized("inactive user".to_string()));
+    }
+
+    let parsed_hash = PasswordHash::new(&user.password_hash)
+        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
+    Argon2::default()
+        .verify_password(password.as_bytes(), &parsed_hash)
+        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
+
+    Ok(user)
+}
+
+fn validate_user_name(name: &str) -> AppResult<()> {
+    validate_name(name, &[".git", ".wiki"], "username")
+}
+
+fn validate_repo_name(name: &str) -> AppResult<()> {
+    validate_name(name, &[".git", ".wiki"], "repository name")
+}
+
+fn validate_name(name: &str, forbidden_suffixes: &[&str], field: &str) -> AppResult<()> {
+    if name.is_empty() {
+        return Err(AppError::Validation(format!("{field} cannot be empty")));
+    }
+    if matches!(name, "." | "..") {
+        return Err(AppError::Validation(format!("{field} is reserved")));
+    }
+    if forbidden_suffixes
+        .iter()
+        .any(|suffix| name.ends_with(suffix))
+    {
+        return Err(AppError::Validation(format!("{field} has reserved suffix")));
+    }
+    if !name
+        .chars()
+        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
+    {
+        return Err(AppError::Validation(format!(
+            "{field} must contain only ASCII letters, digits, '-', '_' or '.'"
+        )));
+    }
+    Ok(())
+}
+
+fn validate_email(email: &str) -> AppResult<()> {
+    if email.contains('@') && !email.starts_with('@') && !email.ends_with('@') {
+        return Ok(());
+    }
+    Err(AppError::Validation("email is invalid".to_string()))
+}
+
+fn ensure_repo_owner(acting_user: &User, repo: &RepositoryWithOwner) -> AppResult<()> {
+    if acting_user.id != repo.owner.id {
+        return Err(AppError::Forbidden(
+            "only repository owner can manage collaborators".to_string(),
+        ));
+    }
+    Ok(())
+}
+
+fn filter_visible_repositories(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repos: Vec<RepositoryWithOwner>,
+    query: &str,
+) -> AppResult<Vec<RepositoryWithOwner>> {
+    let query = query.trim().to_ascii_lowercase();
+    let mut visible = Vec::new();
+    for repo in repos {
+        let mode = effective_access_mode(state, requesting_user, &repo)?;
+        if mode == AccessMode::None {
+            continue;
+        }
+        if !query.is_empty() && !repository_matches_query(&repo, &query) {
+            continue;
+        }
+        visible.push(repo);
+    }
+    Ok(visible)
+}
+
+fn repository_matches_query(repo: &RepositoryWithOwner, query: &str) -> bool {
+    repo.repo.lower_name.contains(query)
+        || repo.owner.lower_name.contains(query)
+        || repo
+            .repo
+            .description
+            .to_ascii_lowercase()
+            .contains(query)
+        || format!("{}/{}", repo.owner.lower_name, repo.repo.lower_name).contains(query)
+}
+
+fn effective_access_mode(
+    state: &AppState,
+    requesting_user: Option<&User>,
+    repo: &RepositoryWithOwner,
+) -> AppResult<AccessMode> {
+    let user_id = requesting_user.map(|user| user.id).unwrap_or(0);
+    state
+        .db
+        .access_mode(user_id, repo.repo.id, repo.owner.id, repo.repo.is_private)
+}
+
+fn random_token() -> String {
+    let mut bytes = [0_u8; 32];
+    OsRng.fill_bytes(&mut bytes);
+    hex::encode(bytes)
+}
+
+fn hash_token(token: &str) -> String {
+    hex::encode(Sha256::digest(token.as_bytes()))
+}

+ 1923 - 0
tests/core_flow.rs

@@ -0,0 +1,1923 @@
+use std::{
+    fs,
+    path::{Path, PathBuf},
+    process::Command,
+    sync::Arc,
+    time::{SystemTime, UNIX_EPOCH},
+};
+
+use actix_http::Request;
+use actix_web::{
+    App,
+    body::BoxBody,
+    dev::{Service, ServiceResponse},
+    http::StatusCode,
+    test,
+};
+use gitr::{
+    app::AppState,
+    conf::{AppConfig, CoreAppConfig, DatabaseConfig, RepositoryConfig, ServerConfig},
+    db::Database,
+    http::build_scope,
+    models::{
+        AccessMode, ApiCollaboratorResponse, ApiLoginResponse, ApiPullRequestDetailResponse,
+        ApiPullRequestResponse, ApiRepositoryResponse, ApiUser, Branch, CompareResponse,
+        CreateAccessTokenResponse, PullRequestStatus,
+    },
+};
+use serde_json::Value;
+
+#[actix_web::test]
+async fn create_user_and_bare_repo_via_http() {
+    let env = TestEnv::new("bare");
+    let app = env.app().await;
+
+    let user = create_user(&app, "alice").await;
+    assert_eq!(user.name, "alice");
+
+    let token = login(&app, "alice").await.token;
+    let repo = create_repo(&app, &token, "demo", false).await;
+    assert_eq!(repo.owner.name, "alice");
+    assert_eq!(repo.repo.name, "demo");
+    assert!(repo.repo.is_bare);
+
+    let repo_path = env.repo_path("alice", "demo");
+    assert!(repo_path.exists());
+    assert!(repo_path.join("HEAD").exists());
+    assert_eq!(
+        git(&repo_path, &["symbolic-ref", "HEAD"]),
+        "refs/heads/main"
+    );
+}
+
+#[actix_web::test]
+async fn create_repo_with_auto_init_creates_first_commit() {
+    let env = TestEnv::new("autoinit");
+    let app = env.app().await;
+
+    create_user(&app, "bob").await;
+    let token = login(&app, "bob").await.token;
+    let repo = create_repo(&app, &token, "seeded", true).await;
+    assert!(!repo.repo.is_bare);
+
+    let repo_path = env.repo_path("bob", "seeded");
+    let head = git(&repo_path, &["rev-parse", "refs/heads/main"]);
+    assert_eq!(head.len(), 40);
+
+    let readme = git(&repo_path, &["show", "refs/heads/main:README.md"]);
+    assert_eq!(readme, "# seeded");
+}
+
+#[actix_web::test]
+async fn duplicate_user_is_rejected() {
+    let env = TestEnv::new("duplicate-user");
+    let app = env.app().await;
+
+    create_user(&app, "carol").await;
+    let admin_token = login(&app, "carol").await.token;
+
+    let request = test::TestRequest::post()
+        .uri("/api/admin/users")
+        .insert_header(("authorization", format!("Bearer {admin_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"username":"carol","email":"carol@example.com","password":"password123"}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::CONFLICT);
+    assert_error_response(
+        response,
+        StatusCode::CONFLICT,
+        "conflict",
+        "user already exists: carol",
+    )
+    .await;
+}
+
+#[actix_web::test]
+async fn duplicate_repo_is_rejected() {
+    let env = TestEnv::new("duplicate-repo");
+    let app = env.app().await;
+
+    create_user(&app, "dave").await;
+    let token = login(&app, "dave").await.token;
+    create_repo(&app, &token, "demo", false).await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"demo","description":"again","auto_init":false}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+#[actix_web::test]
+async fn missing_authorization_is_rejected() {
+    let env = TestEnv::new("missing-auth");
+    let app = env.app().await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"demo","description":"demo","auto_init":false}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_error_response(
+        response,
+        StatusCode::UNAUTHORIZED,
+        "unauthorized",
+        "missing authorization header",
+    )
+    .await;
+}
+
+#[actix_web::test]
+async fn invalid_repo_name_is_rejected() {
+    let env = TestEnv::new("invalid-repo");
+    let app = env.app().await;
+
+    create_user(&app, "erin").await;
+    let token = login(&app, "erin").await.token;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"bad/name","description":"demo","auto_init":false}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_error_response(
+        response,
+        StatusCode::BAD_REQUEST,
+        "validation_error",
+        "repository name must contain only ASCII letters, digits, '-', '_' or '.'",
+    )
+    .await;
+}
+
+#[actix_web::test]
+async fn invalid_user_name_is_rejected() {
+    let env = TestEnv::new("invalid-user");
+    let app = env.app().await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/admin/users")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(
+            r#"{"username":"bad/name","email":"bad@example.com","password":"password123"}"#,
+        )
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[actix_web::test]
+async fn git_init_failure_does_not_leave_repo_record() {
+    let env = TestEnv::new("git-init-failure");
+    let app = env
+        .app_with_git_binary("definitely-not-a-real-git-binary")
+        .await;
+
+    create_user(&app, "frank").await;
+    let token = login(&app, "frank").await.token;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"blocked","description":"demo","auto_init":false}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_error_response(
+        response,
+        StatusCode::INTERNAL_SERVER_ERROR,
+        "internal_error",
+        "internal server error",
+    )
+    .await;
+
+    let get_request = test::TestRequest::get()
+        .uri("/api/repos/frank/blocked")
+        .to_request();
+    let get_response = test::call_service(&app, get_request).await;
+
+    assert_eq!(get_response.status(), StatusCode::NOT_FOUND);
+}
+
+#[actix_web::test]
+async fn login_rejects_bad_password() {
+    let env = TestEnv::new("bad-login");
+    let app = env.app().await;
+
+    create_user(&app, "grace").await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/user/login")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"login":"grace","password":"wrong-password"}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[actix_web::test]
+async fn token_endpoint_creates_second_token() {
+    let env = TestEnv::new("token-endpoint");
+    let app = env.app().await;
+
+    create_user(&app, "heidi").await;
+    let login = login(&app, "heidi").await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"cli"}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    let token: CreateAccessTokenResponse = test::read_body_json(response).await;
+    assert_eq!(token.name, "cli");
+    assert!(!token.token.is_empty());
+    assert_eq!(token.updated_unix, 0);
+}
+
+#[actix_web::test]
+async fn access_token_names_must_be_unique_per_user() {
+    let env = TestEnv::new("token-unique");
+    let app = env.app().await;
+
+    create_user(&app, "alice").await;
+    let login = login(&app, "alice").await;
+
+    let first = test::TestRequest::post()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"cli"}"#)
+        .to_request();
+    let first_response = test::call_service(&app, first).await;
+    assert_eq!(first_response.status(), StatusCode::OK);
+
+    let second = test::TestRequest::post()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"cli"}"#)
+        .to_request();
+    let second_response = test::call_service(&app, second).await;
+    assert_eq!(second_response.status(), StatusCode::CONFLICT);
+}
+
+#[actix_web::test]
+async fn access_token_can_be_listed_and_deleted() {
+    let env = TestEnv::new("token-list-delete");
+    let app = env.app().await;
+
+    create_user(&app, "alice").await;
+    let login = login(&app, "alice").await;
+
+    let create = test::TestRequest::post()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"cli"}"#)
+        .to_request();
+    let create_response = test::call_service(&app, create).await;
+    assert_eq!(create_response.status(), StatusCode::OK);
+    let token: CreateAccessTokenResponse = test::read_body_json(create_response).await;
+    assert_eq!(token.updated_unix, 0);
+
+    let list = test::TestRequest::get()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .to_request();
+    let list_response = test::call_service(&app, list).await;
+    assert_eq!(list_response.status(), StatusCode::OK);
+    let list_body: Value = test::read_body_json(list_response).await;
+    let list_entries = list_body
+        .as_array()
+        .expect("token list response should be an array");
+    assert_eq!(list_entries.len(), 2);
+    assert!(list_entries
+        .iter()
+        .any(|entry| entry.get("id").and_then(Value::as_i64) == Some(token.id)));
+    assert!(list_entries.iter().all(|entry| entry.get("token").is_none()));
+
+    let delete = test::TestRequest::delete()
+        .uri(&format!("/api/user/tokens/{}", token.id))
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .to_request();
+    let delete_response = test::call_service(&app, delete).await;
+    assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
+
+    let list_again = test::TestRequest::get()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .to_request();
+    let list_again_response = test::call_service(&app, list_again).await;
+    assert_eq!(list_again_response.status(), StatusCode::OK);
+    let list_again_body: Value = test::read_body_json(list_again_response).await;
+    let list_again_entries = list_again_body
+        .as_array()
+        .expect("token list response should be an array");
+    assert_eq!(list_again_entries.len(), 1);
+    assert!(list_again_entries
+        .iter()
+        .all(|entry| entry.get("id").and_then(Value::as_i64) != Some(token.id)));
+}
+
+#[actix_web::test]
+async fn access_token_updated_unix_changes_after_use() {
+    let env = TestEnv::new("token-touch");
+    let app = env.app().await;
+
+    create_user(&app, "alice").await;
+    let login = login(&app, "alice").await;
+
+    let create = test::TestRequest::post()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"cli"}"#)
+        .to_request();
+    let create_response = test::call_service(&app, create).await;
+    assert_eq!(create_response.status(), StatusCode::OK);
+    let token: CreateAccessTokenResponse = test::read_body_json(create_response).await;
+    assert_eq!(token.updated_unix, 0);
+
+    let use_cli = test::TestRequest::get()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", token.token)))
+        .to_request();
+    let use_cli_response = test::call_service(&app, use_cli).await;
+    assert_eq!(use_cli_response.status(), StatusCode::OK);
+
+    let list = test::TestRequest::get()
+        .uri("/api/user/tokens")
+        .insert_header(("authorization", format!("Bearer {}", login.token)))
+        .to_request();
+    let list_response = test::call_service(&app, list).await;
+    assert_eq!(list_response.status(), StatusCode::OK);
+    let list_body: Value = test::read_body_json(list_response).await;
+    let list_entries = list_body
+        .as_array()
+        .expect("token list response should be an array");
+    let cli_entry = list_entries
+        .iter()
+        .find(|entry| entry.get("id").and_then(Value::as_i64) == Some(token.id))
+        .expect("cli token should exist");
+    assert!(
+        cli_entry
+            .get("updated_unix")
+            .and_then(Value::as_i64)
+            .unwrap_or_default()
+            > 0
+    );
+}
+
+#[actix_web::test]
+async fn admin_user_creation_requires_bootstrap_or_admin_token() {
+    let env = TestEnv::new("admin-auth");
+    let app = env.app().await;
+
+    create_user(&app, "admin").await;
+    let admin_token = login(&app, "admin").await.token;
+
+    let anonymous = test::TestRequest::post()
+        .uri("/api/admin/users")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"username":"member","email":"member@example.com","password":"password123"}"#)
+        .to_request();
+    let anonymous_response = test::call_service(&app, anonymous).await;
+    assert_eq!(anonymous_response.status(), StatusCode::UNAUTHORIZED);
+
+    let member = create_user_as_admin(&app, &admin_token, "member").await;
+    assert_eq!(member.name, "member");
+}
+
+#[actix_web::test]
+async fn api_responses_do_not_expose_password_hash() {
+    let env = TestEnv::new("redaction");
+    let app = env.app().await;
+
+    create_user(&app, "alice").await;
+    let token = login(&app, "alice").await.token;
+
+    let user_request = test::TestRequest::get().uri("/api/users/alice").to_request();
+    let user_response = test::call_service(&app, user_request).await;
+    assert_eq!(user_response.status(), StatusCode::OK);
+    let user_body: Value = test::read_body_json(user_response).await;
+    assert!(user_body.get("password_hash").is_none());
+    assert_eq!(user_body.get("email").and_then(Value::as_str), Some(""));
+
+    let repo_request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"demo","description":"demo","auto_init":false}"#)
+        .to_request();
+    let repo_response = test::call_service(&app, repo_request).await;
+    assert_eq!(repo_response.status(), StatusCode::OK);
+    let repo_body: Value = test::read_body_json(repo_response).await;
+    assert!(
+        repo_body
+            .get("owner")
+            .and_then(|owner| owner.get("password_hash"))
+            .is_none()
+    );
+}
+
+#[actix_web::test]
+async fn private_repo_metadata_is_not_visible_without_read_access() {
+    let env = TestEnv::new("private-repo-opaque");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_repo_with_visibility(&app, &owner_token, "secret", true, true).await;
+    create_user_as_admin(&app, &owner_token, "outsider").await;
+    let outsider_token = login(&app, "outsider").await.token;
+
+    let anonymous = test::TestRequest::get()
+        .uri("/api/repos/owner/secret")
+        .to_request();
+    let anonymous_response = test::call_service(&app, anonymous).await;
+    assert_error_response(
+        anonymous_response,
+        StatusCode::NOT_FOUND,
+        "not_found",
+        "repository not found: owner/secret",
+    )
+    .await;
+
+    let outsider = test::TestRequest::get()
+        .uri("/api/repos/owner/secret")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let outsider_response = test::call_service(&app, outsider).await;
+    assert_error_response(
+        outsider_response,
+        StatusCode::NOT_FOUND,
+        "not_found",
+        "repository not found: owner/secret",
+    )
+    .await;
+}
+
+#[actix_web::test]
+async fn private_repo_read_endpoints_are_not_visible_without_access() {
+    let env = TestEnv::new("private-repo-read-opaque");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_repo_with_visibility(&app, &owner_token, "secret", true, true).await;
+    create_user_as_admin(&app, &owner_token, "outsider").await;
+    let outsider_token = login(&app, "outsider").await.token;
+
+    let branches = test::TestRequest::get()
+        .uri("/api/repos/owner/secret/branches")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let branches_response = test::call_service(&app, branches).await;
+    assert_eq!(branches_response.status(), StatusCode::NOT_FOUND);
+
+    let pulls = test::TestRequest::get()
+        .uri("/api/repos/owner/secret/pulls")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let pulls_response = test::call_service(&app, pulls).await;
+    assert_eq!(pulls_response.status(), StatusCode::NOT_FOUND);
+
+    let forks = test::TestRequest::post()
+        .uri("/api/repos/owner/secret/forks")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"secret-fork","description":"x"}"#)
+        .to_request();
+    let forks_response = test::call_service(&app, forks).await;
+    assert_eq!(forks_response.status(), StatusCode::NOT_FOUND);
+
+    let compare = test::TestRequest::get()
+        .uri("/api/repos/owner/secret/compare?base=main&head_owner=owner&head_repo=secret&head_branch=main")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let compare_response = test::call_service(&app, compare).await;
+    assert_eq!(compare_response.status(), StatusCode::NOT_FOUND);
+
+    let create_pr = test::TestRequest::post()
+        .uri("/api/repos/owner/secret/pulls")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(
+            r#"{"head_owner":"owner","head_repo":"secret","head_branch":"main","base_branch":"main","title":"x","body":""}"#,
+        )
+        .to_request();
+    let create_pr_response = test::call_service(&app, create_pr).await;
+    assert_eq!(create_pr_response.status(), StatusCode::NOT_FOUND);
+}
+
+#[actix_web::test]
+async fn list_user_repositories_hides_private_repos_without_access() {
+    let env = TestEnv::new("list-user-repos");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_repo_with_visibility(&app, &owner_token, "public", true, false).await;
+    create_repo_with_visibility(&app, &owner_token, "secret", true, true).await;
+    create_user_as_admin(&app, &owner_token, "outsider").await;
+    let outsider_token = login(&app, "outsider").await.token;
+
+    let anonymous_repos = list_user_repositories(&app, None, "owner", "").await;
+    assert_eq!(anonymous_repos.len(), 1);
+    assert_eq!(anonymous_repos[0].repo.name, "public");
+    assert_eq!(anonymous_repos[0].permission.mode, AccessMode::Read);
+
+    let outsider_repos = list_user_repositories(&app, Some(&outsider_token), "owner", "").await;
+    assert_eq!(outsider_repos.len(), 1);
+    assert_eq!(outsider_repos[0].repo.name, "public");
+    assert!(outsider_repos[0].permission.can_read);
+    assert!(!outsider_repos[0].permission.can_write);
+
+    let owner_repos = list_user_repositories(&app, Some(&owner_token), "owner", "").await;
+    assert_eq!(owner_repos.len(), 2);
+    assert!(owner_repos.iter().any(|repo| repo.repo.name == "secret"));
+    assert!(owner_repos.iter().all(|repo| repo.permission.is_owner));
+}
+
+#[actix_web::test]
+async fn current_user_repo_list_includes_visible_repositories() {
+    let env = TestEnv::new("current-user-repos");
+    let app = env.app().await;
+
+    create_user(&app, "alice").await;
+    let alice_token = login(&app, "alice").await.token;
+    create_repo_with_visibility(&app, &alice_token, "own-public", true, false).await;
+    create_repo_with_visibility(&app, &alice_token, "own-private", true, true).await;
+
+    create_user_as_admin(&app, &alice_token, "bob").await;
+    let bob_token = login(&app, "bob").await.token;
+    create_repo_with_visibility(&app, &bob_token, "bob-public", true, false).await;
+    create_repo_with_visibility(&app, &bob_token, "bob-shared", true, true).await;
+    add_collaborator(&app, &bob_token, "bob", "bob-shared", "alice", "read").await;
+
+    let repos = list_current_user_repositories(&app, &alice_token, "").await;
+    assert_eq!(repos.len(), 4);
+    assert!(repos.iter().any(|repo| {
+        repo.owner.name == "alice" && repo.repo.name == "own-private" && repo.permission.is_owner
+    }));
+    assert!(repos.iter().any(|repo| {
+        repo.owner.name == "bob"
+            && repo.repo.name == "bob-public"
+            && repo.permission.can_read
+            && !repo.permission.can_write
+    }));
+    assert!(repos.iter().any(|repo| {
+        repo.owner.name == "bob"
+            && repo.repo.name == "bob-shared"
+            && repo.permission.can_read
+            && !repo.permission.can_write
+    }));
+}
+
+#[actix_web::test]
+async fn repository_search_filters_to_visible_results() {
+    let env = TestEnv::new("search-repos");
+    let app = env.app().await;
+
+    create_user(&app, "searcher").await;
+    let searcher_token = login(&app, "searcher").await.token;
+    create_user_as_admin(&app, &searcher_token, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_repo_with_visibility(&app, &owner_token, "rust-public", true, false).await;
+    create_repo_with_visibility(&app, &owner_token, "python-public", true, false).await;
+    create_repo_with_visibility(&app, &owner_token, "rust-secret", true, true).await;
+    add_collaborator(&app, &owner_token, "owner", "rust-secret", "searcher", "read").await;
+
+    let anonymous = search_repositories(&app, None, "rust").await;
+    assert_eq!(anonymous.len(), 1);
+    assert_eq!(anonymous[0].repo.name, "rust-public");
+
+    let authed = search_repositories(&app, Some(&searcher_token), "rust").await;
+    assert_eq!(authed.len(), 2);
+    assert!(authed.iter().any(|repo| repo.repo.name == "rust-public"));
+    assert!(authed.iter().any(|repo| repo.repo.name == "rust-secret"));
+}
+
+#[actix_web::test]
+async fn invalid_collaborator_permission_is_rejected() {
+    let env = TestEnv::new("invalid-collab-permission");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_user_as_admin(&app, &owner_token, "guest").await;
+    create_repo_with_visibility(&app, &owner_token, "shared", true, true).await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos/owner/shared/collaborators")
+        .insert_header(("authorization", format!("Bearer {owner_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"username":"guest","permission":"super"}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[actix_web::test]
+async fn collaborator_list_and_check_return_expected_permissions() {
+    let env = TestEnv::new("list-collaborators");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_user_as_admin(&app, &owner_token, "reader").await;
+    create_user_as_admin(&app, &owner_token, "adminer").await;
+    create_repo_with_visibility(&app, &owner_token, "shared", true, true).await;
+    add_collaborator(&app, &owner_token, "owner", "shared", "reader", "read").await;
+    add_collaborator(&app, &owner_token, "owner", "shared", "adminer", "admin").await;
+
+    let collaborators = list_collaborators(&app, Some(&owner_token), "owner", "shared").await;
+    assert_eq!(collaborators.len(), 2);
+    assert_eq!(collaborators[0].user.name, "adminer");
+    assert_eq!(collaborators[1].user.name, "reader");
+
+    let reader = get_collaborator(&app, Some(&owner_token), "owner", "shared", "reader").await;
+    assert_eq!(reader.user.name, "reader");
+    assert_eq!(format!("{:?}", reader.mode), "Read");
+}
+
+#[actix_web::test]
+async fn private_collaborator_endpoints_are_not_visible_without_access() {
+    let env = TestEnv::new("private-collaborator-opaque");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_repo_with_visibility(&app, &owner_token, "secret", true, true).await;
+    create_user_as_admin(&app, &owner_token, "outsider").await;
+    let outsider_token = login(&app, "outsider").await.token;
+
+    let list = test::TestRequest::get()
+        .uri("/api/repos/owner/secret/collaborators")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let list_response = test::call_service(&app, list).await;
+    assert_eq!(list_response.status(), StatusCode::NOT_FOUND);
+
+    let get = test::TestRequest::get()
+        .uri("/api/repos/owner/secret/collaborators/outsider")
+        .insert_header(("authorization", format!("Bearer {outsider_token}")))
+        .to_request();
+    let get_response = test::call_service(&app, get).await;
+    assert_eq!(get_response.status(), StatusCode::NOT_FOUND);
+}
+
+#[actix_web::test]
+async fn public_git_info_refs_allows_anonymous_pull() {
+    let env = TestEnv::new("public-git-http");
+    let app = env.app().await;
+
+    create_user(&app, "ivan").await;
+    let token = login(&app, "ivan").await.token;
+    create_repo_with_visibility(&app, &token, "public", true, false).await;
+
+    let request = test::TestRequest::get()
+        .uri("/ivan/public.git/info/refs?service=git-upload-pack")
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    assert_eq!(
+        response
+            .headers()
+            .get("content-type")
+            .and_then(|v| v.to_str().ok()),
+        Some("application/x-git-upload-pack-advertisement")
+    );
+}
+
+#[actix_web::test]
+async fn private_git_info_refs_requires_basic_auth() {
+    let env = TestEnv::new("private-git-http");
+    let app = env.app().await;
+
+    create_user(&app, "judy").await;
+    let token = login(&app, "judy").await.token;
+    create_repo_with_visibility(&app, &token, "private", true, true).await;
+
+    let unauthenticated = test::TestRequest::get()
+        .uri("/judy/private.git/info/refs?service=git-upload-pack")
+        .to_request();
+    let unauthenticated_response = test::call_service(&app, unauthenticated).await;
+    assert_eq!(unauthenticated_response.status(), StatusCode::UNAUTHORIZED);
+
+    let basic = basic_auth_header("judy", "password123");
+    let authenticated = test::TestRequest::get()
+        .uri("/judy/private.git/info/refs?service=git-upload-pack")
+        .insert_header(("authorization", basic))
+        .to_request();
+    let authenticated_response = test::call_service(&app, authenticated).await;
+    assert_eq!(authenticated_response.status(), StatusCode::OK);
+}
+
+#[actix_web::test]
+async fn private_git_info_refs_allows_read_collaborator() {
+    let env = TestEnv::new("private-collab-read");
+    let app = env.app().await;
+
+    create_user(&app, "kate").await;
+    let owner_token = login(&app, "kate").await.token;
+    create_user_as_admin(&app, &owner_token, "louis").await;
+    create_repo_with_visibility(&app, &owner_token, "shared", true, true).await;
+    add_collaborator(&app, &owner_token, "kate", "shared", "louis", "read").await;
+
+    let collaborator = test::TestRequest::get()
+        .uri("/kate/shared.git/info/refs?service=git-upload-pack")
+        .insert_header(("authorization", basic_auth_header("louis", "password123")))
+        .to_request();
+    let collaborator_response = test::call_service(&app, collaborator).await;
+    assert_eq!(collaborator_response.status(), StatusCode::OK);
+}
+
+#[actix_web::test]
+async fn read_collaborator_cannot_advertise_receive_pack() {
+    let env = TestEnv::new("private-collab-read-no-push");
+    let app = env.app().await;
+
+    create_user(&app, "mike").await;
+    let owner_token = login(&app, "mike").await.token;
+    create_user_as_admin(&app, &owner_token, "nina").await;
+    create_repo_with_visibility(&app, &owner_token, "shared", true, true).await;
+    add_collaborator(&app, &owner_token, "mike", "shared", "nina", "read").await;
+
+    let collaborator = test::TestRequest::get()
+        .uri("/mike/shared.git/info/refs?service=git-receive-pack")
+        .insert_header(("authorization", basic_auth_header("nina", "password123")))
+        .to_request();
+    let collaborator_response = test::call_service(&app, collaborator).await;
+    assert_eq!(collaborator_response.status(), StatusCode::FORBIDDEN);
+}
+
+#[actix_web::test]
+async fn fork_repository_clones_base_repo() {
+    let env = TestEnv::new("fork-repository");
+    let app = env.app().await;
+
+    create_user(&app, "olivia").await;
+    let owner_token = login(&app, "olivia").await.token;
+    create_user_as_admin(&app, &owner_token, "peter").await;
+    let forker_token = login(&app, "peter").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos/olivia/origin/forks")
+        .insert_header(("authorization", format!("Bearer {forker_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"name":"origin-fork","description":"forked"}"#)
+        .to_request();
+    let response = test::call_service(&app, request).await;
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let fork_path = env.repo_path("peter", "origin-fork");
+    assert!(fork_path.exists());
+    let head = git(&fork_path, &["rev-parse", "refs/heads/main"]);
+    assert_eq!(head.len(), 40);
+}
+
+#[actix_web::test]
+async fn list_branches_returns_main_branch() {
+    let env = TestEnv::new("list-branches");
+    let app = env.app().await;
+
+    create_user(&app, "quinn").await;
+    let token = login(&app, "quinn").await.token;
+    create_repo_with_visibility(&app, &token, "branches", true, false).await;
+
+    let request = test::TestRequest::get()
+        .uri("/api/repos/quinn/branches/branches")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(&app, request).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    let branches: Vec<Branch> = test::read_body_json(response).await;
+    assert!(branches.iter().any(|branch| branch.name == "main"));
+}
+
+#[actix_web::test]
+async fn create_pull_request_from_fork_succeeds() {
+    let env = TestEnv::new("create-pr");
+    let app = env.app().await;
+
+    create_user(&app, "rachel").await;
+    let owner_token = login(&app, "rachel").await.token;
+    create_user_as_admin(&app, &owner_token, "sam").await;
+    let forker_token = login(&app, "sam").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "rachel", "origin", "origin-fork").await;
+
+    push_commit_to_branch(
+        &env.repo_path("sam", "origin-fork"),
+        "main",
+        "feature-one",
+        "sam",
+        "sam@example.com",
+        "feature.txt",
+        "hello from fork\n",
+    );
+
+    let pull = create_pull_request(
+        &app,
+        &forker_token,
+        "rachel",
+        "origin",
+        "sam",
+        "origin-fork",
+        "feature-one",
+        "main",
+        "Add feature one",
+    )
+    .await;
+
+    assert_eq!(pull.base_repo.owner.name, "rachel");
+    assert_eq!(pull.base_repo.repo.name, "origin");
+    assert_eq!(pull.head_repo.owner.name, "sam");
+    assert_eq!(pull.head_repo.repo.name, "origin-fork");
+    assert_eq!(pull.pull_request.index, 1);
+    assert_eq!(pull.pull_request.head_branch, "feature-one");
+    assert_eq!(pull.pull_request.base_branch, "main");
+    assert_eq!(pull.pull_request.status, PullRequestStatus::Mergeable);
+    assert!(!pull.pull_request.merge_base.is_empty());
+}
+
+#[actix_web::test]
+async fn compare_endpoint_returns_commit_and_file_stats() {
+    let env = TestEnv::new("compare-pr");
+    let app = env.app().await;
+
+    create_user(&app, "rhea").await;
+    let owner_token = login(&app, "rhea").await.token;
+    create_user_as_admin(&app, &owner_token, "sora").await;
+    let forker_token = login(&app, "sora").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "rhea", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("sora", "origin-fork"),
+        "main",
+        "feature-compare",
+        "sora",
+        "sora@example.com",
+        "compare.txt",
+        "compare body\n",
+    );
+
+    let compare = compare_repositories(
+        &app,
+        &forker_token,
+        "rhea",
+        "origin",
+        "main",
+        "sora",
+        "origin-fork",
+        "feature-compare",
+    )
+    .await;
+
+    assert_eq!(compare.base_branch, "main");
+    assert_eq!(compare.head_branch, "feature-compare");
+    assert_eq!(compare.status, PullRequestStatus::Mergeable);
+    assert_eq!(compare.commits.len(), 1);
+    assert_eq!(compare.files.len(), 1);
+    assert_eq!(compare.files[0].path, "compare.txt");
+    assert!(!compare.head_commit_id.is_empty());
+    assert!(!compare.merge_base.is_empty());
+}
+
+#[actix_web::test]
+async fn duplicate_unmerged_pull_request_is_rejected() {
+    let env = TestEnv::new("duplicate-pr");
+    let app = env.app().await;
+
+    create_user(&app, "tina").await;
+    let owner_token = login(&app, "tina").await.token;
+    create_user_as_admin(&app, &owner_token, "uma").await;
+    let forker_token = login(&app, "uma").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "tina", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("uma", "origin-fork"),
+        "main",
+        "feature-one",
+        "uma",
+        "uma@example.com",
+        "feature.txt",
+        "duplicate pr check\n",
+    );
+
+    let _ = create_pull_request(
+        &app,
+        &forker_token,
+        "tina",
+        "origin",
+        "uma",
+        "origin-fork",
+        "feature-one",
+        "main",
+        "First PR",
+    )
+    .await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos/tina/origin/pulls")
+        .insert_header(("authorization", format!("Bearer {forker_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(
+            r#"{"head_owner":"uma","head_repo":"origin-fork","head_branch":"feature-one","base_branch":"main","title":"First PR","body":""}"#,
+        )
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+#[actix_web::test]
+async fn list_pull_requests_returns_created_pull_request() {
+    let env = TestEnv::new("list-prs");
+    let app = env.app().await;
+
+    create_user(&app, "victor").await;
+    let owner_token = login(&app, "victor").await.token;
+    create_user_as_admin(&app, &owner_token, "wendy").await;
+    let forker_token = login(&app, "wendy").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "victor", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("wendy", "origin-fork"),
+        "main",
+        "feature-list",
+        "wendy",
+        "wendy@example.com",
+        "list.txt",
+        "list pull requests\n",
+    );
+
+    let created = create_pull_request(
+        &app,
+        &forker_token,
+        "victor",
+        "origin",
+        "wendy",
+        "origin-fork",
+        "feature-list",
+        "main",
+        "List PR",
+    )
+    .await;
+
+    let request = test::TestRequest::get()
+        .uri("/api/repos/victor/origin/pulls")
+        .insert_header(("authorization", format!("Bearer {owner_token}")))
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    let pulls: Vec<ApiPullRequestResponse> = test::read_body_json(response).await;
+    assert_eq!(pulls.len(), 1);
+    assert_eq!(pulls[0].pull_request.id, created.pull_request.id);
+    assert_eq!(pulls[0].pull_request.title, "List PR");
+    assert_eq!(pulls[0].head_repo.repo.name, "origin-fork");
+}
+
+#[actix_web::test]
+async fn get_pull_request_detail_returns_compare_payload() {
+    let env = TestEnv::new("pr-detail");
+    let app = env.app().await;
+
+    create_user(&app, "trent").await;
+    let owner_token = login(&app, "trent").await.token;
+    create_user_as_admin(&app, &owner_token, "ursula").await;
+    let forker_token = login(&app, "ursula").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "trent", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("ursula", "origin-fork"),
+        "main",
+        "feature-detail",
+        "ursula",
+        "ursula@example.com",
+        "detail.txt",
+        "detail body\n",
+    );
+
+    let created = create_pull_request(
+        &app,
+        &forker_token,
+        "trent",
+        "origin",
+        "ursula",
+        "origin-fork",
+        "feature-detail",
+        "main",
+        "Detail PR",
+    )
+    .await;
+
+    let detail = get_pull_request(&app, &owner_token, "trent", "origin", 1).await;
+    assert_eq!(detail.pull_request.id, created.pull_request.id);
+    assert_eq!(detail.pull_request.title, "Detail PR");
+    assert_eq!(detail.compare.status, PullRequestStatus::Mergeable);
+    assert_eq!(detail.compare.commits.len(), 1);
+    assert_eq!(detail.compare.files.len(), 1);
+    assert_eq!(detail.compare.files[0].path, "detail.txt");
+}
+
+#[actix_web::test]
+async fn merge_pull_request_updates_base_branch() {
+    let env = TestEnv::new("merge-pr");
+    let app = env.app().await;
+
+    create_user(&app, "xavier").await;
+    let owner_token = login(&app, "xavier").await.token;
+    create_user_as_admin(&app, &owner_token, "yara").await;
+    let forker_token = login(&app, "yara").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "xavier", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("yara", "origin-fork"),
+        "main",
+        "feature-merge",
+        "yara",
+        "yara@example.com",
+        "merged.txt",
+        "merged by pr\n",
+    );
+
+    let created = create_pull_request(
+        &app,
+        &forker_token,
+        "xavier",
+        "origin",
+        "yara",
+        "origin-fork",
+        "feature-merge",
+        "main",
+        "Merge PR",
+    )
+    .await;
+
+    let merged = merge_pull_request(&app, &owner_token, "xavier", "origin", 1).await;
+    assert_eq!(merged.pull_request.id, created.pull_request.id);
+    assert!(merged.pull_request.has_merged);
+    assert!(merged.pull_request.is_closed);
+
+    let merged_file = git(
+        &env.repo_path("xavier", "origin"),
+        &["show", "refs/heads/main:merged.txt"],
+    );
+    assert_eq!(merged_file, "merged by pr");
+}
+
+#[actix_web::test]
+async fn merged_pull_request_detail_excludes_base_only_commits() {
+    let env = TestEnv::new("merged-pr-compare");
+    let app = env.app().await;
+
+    create_user(&app, "owner").await;
+    let owner_token = login(&app, "owner").await.token;
+    create_user_as_admin(&app, &owner_token, "forker").await;
+    let forker_token = login(&app, "forker").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "owner", "origin", "origin-fork").await;
+
+    push_commit_to_branch(
+        &env.repo_path("forker", "origin-fork"),
+        "main",
+        "feature-merged",
+        "forker",
+        "forker@example.com",
+        "feature.txt",
+        "feature body\n",
+    );
+
+    let _ = create_pull_request(
+        &app,
+        &forker_token,
+        "owner",
+        "origin",
+        "forker",
+        "origin-fork",
+        "feature-merged",
+        "main",
+        "Merged PR",
+    )
+    .await;
+
+    push_commit_to_existing_branch(
+        &env.repo_path("owner", "origin"),
+        "main",
+        "owner",
+        "owner@example.com",
+        "base.txt",
+        "base only\n",
+    );
+
+    let merged = merge_pull_request(&app, &owner_token, "owner", "origin", 1).await;
+    assert!(merged.pull_request.has_merged);
+
+    let detail = get_pull_request(&app, &owner_token, "owner", "origin", 1).await;
+    assert_eq!(detail.compare.commits.len(), 1);
+    assert_eq!(detail.compare.files.len(), 1);
+    assert_eq!(detail.compare.files[0].path, "feature.txt");
+}
+
+#[actix_web::test]
+async fn pull_request_poster_can_close_and_reopen() {
+    let env = TestEnv::new("close-reopen-pr");
+    let app = env.app().await;
+
+    create_user(&app, "zoe").await;
+    let owner_token = login(&app, "zoe").await.token;
+    create_user_as_admin(&app, &owner_token, "abby").await;
+    let forker_token = login(&app, "abby").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "zoe", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("abby", "origin-fork"),
+        "main",
+        "feature-close",
+        "abby",
+        "abby@example.com",
+        "close.txt",
+        "close reopen\n",
+    );
+
+    let created = create_pull_request(
+        &app,
+        &forker_token,
+        "zoe",
+        "origin",
+        "abby",
+        "origin-fork",
+        "feature-close",
+        "main",
+        "Close PR",
+    )
+    .await;
+    assert!(!created.pull_request.is_closed);
+
+    let closed = close_pull_request(&app, &forker_token, "zoe", "origin", 1).await;
+    assert!(closed.pull_request.is_closed);
+    assert!(!closed.pull_request.has_merged);
+
+    let reopened = reopen_pull_request(&app, &forker_token, "zoe", "origin", 1).await;
+    assert!(!reopened.pull_request.is_closed);
+    assert_eq!(reopened.pull_request.status, PullRequestStatus::Mergeable);
+}
+
+#[actix_web::test]
+async fn reopen_pull_request_rejects_duplicate_open_pair() {
+    let env = TestEnv::new("reopen-duplicate-pr");
+    let app = env.app().await;
+
+    create_user(&app, "brad").await;
+    let owner_token = login(&app, "brad").await.token;
+    create_user_as_admin(&app, &owner_token, "cora").await;
+    let forker_token = login(&app, "cora").await.token;
+    create_repo_with_visibility(&app, &owner_token, "origin", true, false).await;
+    fork_repo(&app, &forker_token, "brad", "origin", "origin-fork").await;
+    push_commit_to_branch(
+        &env.repo_path("cora", "origin-fork"),
+        "main",
+        "feature-dup",
+        "cora",
+        "cora@example.com",
+        "dup.txt",
+        "duplicate reopen\n",
+    );
+
+    let _ = create_pull_request(
+        &app,
+        &forker_token,
+        "brad",
+        "origin",
+        "cora",
+        "origin-fork",
+        "feature-dup",
+        "main",
+        "Closed PR",
+    )
+    .await;
+    let _ = close_pull_request(&app, &forker_token, "brad", "origin", 1).await;
+    let _ = create_pull_request(
+        &app,
+        &forker_token,
+        "brad",
+        "origin",
+        "cora",
+        "origin-fork",
+        "feature-dup",
+        "main",
+        "Open PR",
+    )
+    .await;
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos/brad/origin/pulls/1/reopen")
+        .insert_header(("authorization", format!("Bearer {forker_token}")))
+        .to_request();
+    let response = test::call_service(&app, request).await;
+
+    assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+async fn create_user<S>(app: &S, username: &str) -> ApiUser
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let email = format!("{username}@example.com");
+    let body = format!(
+        r#"{{"username":"{username}","email":"{email}","password":"password123","full_name":"{username}"}}"#
+    );
+
+    let request = test::TestRequest::post()
+        .uri("/api/admin/users")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn create_user_as_admin<S>(app: &S, admin_token: &str, username: &str) -> ApiUser
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let email = format!("{username}@example.com");
+    let body = format!(
+        r#"{{"username":"{username}","email":"{email}","password":"password123","full_name":"{username}"}}"#
+    );
+
+    let request = test::TestRequest::post()
+        .uri("/api/admin/users")
+        .insert_header(("authorization", format!("Bearer {admin_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn login<S>(app: &S, login: &str) -> ApiLoginResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let body = format!(r#"{{"login":"{login}","password":"password123"}}"#);
+    let request = test::TestRequest::post()
+        .uri("/api/user/login")
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn create_repo<S>(
+    app: &S,
+    token: &str,
+    name: &str,
+    auto_init: bool,
+) -> ApiRepositoryResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    create_repo_with_visibility(app, token, name, auto_init, false).await
+}
+
+async fn create_repo_with_visibility<S>(
+    app: &S,
+    token: &str,
+    name: &str,
+    auto_init: bool,
+    is_private: bool,
+) -> ApiRepositoryResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let body = format!(
+        r#"{{"name":"{name}","description":"repo {name}","auto_init":{auto_init},"is_private":{is_private}}}"#
+    );
+
+    let request = test::TestRequest::post()
+        .uri("/api/repos")
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn fork_repo<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    fork_name: &str,
+) -> ApiRepositoryResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let body = format!(r#"{{"name":"{fork_name}","description":"fork {fork_name}"}}"#);
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/forks"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn create_pull_request<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    head_owner: &str,
+    head_repo: &str,
+    head_branch: &str,
+    base_branch: &str,
+    title: &str,
+) -> ApiPullRequestResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let body = format!(
+        r#"{{"head_owner":"{head_owner}","head_repo":"{head_repo}","head_branch":"{head_branch}","base_branch":"{base_branch}","title":"{title}","body":"{title} body"}}"#
+    );
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/pulls"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn compare_repositories<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    base: &str,
+    head_owner: &str,
+    head_repo: &str,
+    head_branch: &str,
+) -> CompareResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::get()
+        .uri(&format!(
+            "/api/repos/{owner}/{repo}/compare?base={base}&head_owner={head_owner}&head_repo={head_repo}&head_branch={head_branch}"
+        ))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn get_pull_request<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    index: i64,
+) -> ApiPullRequestDetailResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::get()
+        .uri(&format!("/api/repos/{owner}/{repo}/pulls/{index}"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn merge_pull_request<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    index: i64,
+) -> ApiPullRequestResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/pulls/{index}/merge"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(r#"{"message":""}"#)
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn close_pull_request<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    index: i64,
+) -> ApiPullRequestResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/pulls/{index}/close"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn reopen_pull_request<S>(
+    app: &S,
+    token: &str,
+    owner: &str,
+    repo: &str,
+    index: i64,
+) -> ApiPullRequestResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/pulls/{index}/reopen"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(app, request).await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+fn basic_auth_header(username: &str, password: &str) -> String {
+    format!("Basic {}", encode_base64(&format!("{username}:{password}")))
+}
+
+async fn add_collaborator<S>(
+    app: &S,
+    owner_token: &str,
+    owner: &str,
+    repo: &str,
+    username: &str,
+    permission: &str,
+) where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let body = format!(r#"{{"username":"{username}","permission":"{permission}"}}"#);
+    let request = test::TestRequest::post()
+        .uri(&format!("/api/repos/{owner}/{repo}/collaborators"))
+        .insert_header(("authorization", format!("Bearer {owner_token}")))
+        .insert_header(("content-type", "application/json"))
+        .set_payload(body)
+        .to_request();
+    let response = test::call_service(app, request).await;
+    assert_eq!(response.status(), StatusCode::OK);
+}
+
+async fn assert_error_response(
+    response: ServiceResponse<BoxBody>,
+    expected_status: StatusCode,
+    expected_code: &str,
+    expected_message: &str,
+) {
+    assert_eq!(response.status(), expected_status);
+    let body: Value = test::read_body_json(response).await;
+    assert_eq!(body.get("code").and_then(Value::as_str), Some(expected_code));
+    assert_eq!(
+        body.get("message").and_then(Value::as_str),
+        Some(expected_message)
+    );
+    assert_eq!(
+        body.get("status").and_then(Value::as_u64),
+        Some(expected_status.as_u16() as u64)
+    );
+}
+
+async fn list_current_user_repositories<S>(
+    app: &S,
+    token: &str,
+    query: &str,
+) -> Vec<ApiRepositoryResponse>
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let request = test::TestRequest::get()
+        .uri(&format!("/api/user/repos?q={query}"))
+        .insert_header(("authorization", format!("Bearer {token}")))
+        .to_request();
+    let response = test::call_service(app, request).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn list_user_repositories<S>(
+    app: &S,
+    token: Option<&str>,
+    username: &str,
+    query: &str,
+) -> Vec<ApiRepositoryResponse>
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let mut request = test::TestRequest::get().uri(&format!("/api/users/{username}/repos?q={query}"));
+    if let Some(token) = token {
+        request = request.insert_header(("authorization", format!("Bearer {token}")));
+    }
+    let response = test::call_service(app, request.to_request()).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn search_repositories<S>(
+    app: &S,
+    token: Option<&str>,
+    query: &str,
+) -> Vec<ApiRepositoryResponse>
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let mut request = test::TestRequest::get().uri(&format!("/api/repos/search?q={query}"));
+    if let Some(token) = token {
+        request = request.insert_header(("authorization", format!("Bearer {token}")));
+    }
+    let response = test::call_service(app, request.to_request()).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn list_collaborators<S>(
+    app: &S,
+    token: Option<&str>,
+    owner: &str,
+    repo: &str,
+) -> Vec<ApiCollaboratorResponse>
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let mut request =
+        test::TestRequest::get().uri(&format!("/api/repos/{owner}/{repo}/collaborators"));
+    if let Some(token) = token {
+        request = request.insert_header(("authorization", format!("Bearer {token}")));
+    }
+    let response = test::call_service(app, request.to_request()).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+async fn get_collaborator<S>(
+    app: &S,
+    token: Option<&str>,
+    owner: &str,
+    repo: &str,
+    username: &str,
+) -> ApiCollaboratorResponse
+where
+    S: Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
+{
+    let mut request = test::TestRequest::get()
+        .uri(&format!("/api/repos/{owner}/{repo}/collaborators/{username}"));
+    if let Some(token) = token {
+        request = request.insert_header(("authorization", format!("Bearer {token}")));
+    }
+    let response = test::call_service(app, request.to_request()).await;
+    assert_eq!(response.status(), StatusCode::OK);
+    test::read_body_json(response).await
+}
+
+fn encode_base64(input: &str) -> String {
+    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+    let bytes = input.as_bytes();
+    let mut out = String::new();
+    let mut index = 0;
+    while index < bytes.len() {
+        let b0 = bytes[index];
+        let b1 = *bytes.get(index + 1).unwrap_or(&0);
+        let b2 = *bytes.get(index + 2).unwrap_or(&0);
+
+        out.push(TABLE[(b0 >> 2) as usize] as char);
+        out.push(TABLE[((b0 & 0b0000_0011) << 4 | (b1 >> 4)) as usize] as char);
+
+        if index + 1 < bytes.len() {
+            out.push(TABLE[((b1 & 0b0000_1111) << 2 | (b2 >> 6)) as usize] as char);
+        } else {
+            out.push('=');
+        }
+
+        if index + 2 < bytes.len() {
+            out.push(TABLE[(b2 & 0b0011_1111) as usize] as char);
+        } else {
+            out.push('=');
+        }
+
+        index += 3;
+    }
+    out
+}
+
+fn git(repo_path: &Path, args: &[&str]) -> String {
+    let output = Command::new("git")
+        .arg("--git-dir")
+        .arg(repo_path)
+        .args(args)
+        .output()
+        .expect("run git");
+
+    assert!(
+        output.status.success(),
+        "git command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    String::from_utf8_lossy(&output.stdout).trim().to_string()
+}
+
+fn push_commit_to_branch(
+    repo_path: &Path,
+    base_branch: &str,
+    branch: &str,
+    author_name: &str,
+    author_email: &str,
+    file_name: &str,
+    content: &str,
+) {
+    let unique = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("clock")
+        .as_nanos();
+    let worktree = std::env::temp_dir().join(format!("gitr-pr-work-{branch}-{unique}"));
+    let _ = fs::remove_dir_all(&worktree);
+
+    let clone = Command::new("git")
+        .arg("clone")
+        .arg(repo_path)
+        .arg(&worktree)
+        .output()
+        .expect("clone repo");
+    assert!(
+        clone.status.success(),
+        "git clone failed: {}",
+        String::from_utf8_lossy(&clone.stderr)
+    );
+
+    let checkout = Command::new("git")
+        .current_dir(&worktree)
+        .arg("checkout")
+        .arg("-b")
+        .arg(branch)
+        .arg(format!("origin/{base_branch}"))
+        .output()
+        .expect("checkout branch");
+    assert!(
+        checkout.status.success(),
+        "git checkout failed: {}",
+        String::from_utf8_lossy(&checkout.stderr)
+    );
+
+    fs::write(worktree.join(file_name), content).expect("write test file");
+
+    let add = Command::new("git")
+        .current_dir(&worktree)
+        .arg("add")
+        .arg(file_name)
+        .output()
+        .expect("git add");
+    assert!(
+        add.status.success(),
+        "git add failed: {}",
+        String::from_utf8_lossy(&add.stderr)
+    );
+
+    let commit = Command::new("git")
+        .current_dir(&worktree)
+        .env("GIT_AUTHOR_NAME", author_name)
+        .env("GIT_AUTHOR_EMAIL", author_email)
+        .env("GIT_COMMITTER_NAME", author_name)
+        .env("GIT_COMMITTER_EMAIL", author_email)
+        .arg("commit")
+        .arg("-m")
+        .arg(format!("Add {file_name}"))
+        .output()
+        .expect("git commit");
+    assert!(
+        commit.status.success(),
+        "git commit failed: {}",
+        String::from_utf8_lossy(&commit.stderr)
+    );
+
+    let push = Command::new("git")
+        .current_dir(&worktree)
+        .arg("push")
+        .arg("origin")
+        .arg(format!("HEAD:refs/heads/{branch}"))
+        .output()
+        .expect("git push");
+    assert!(
+        push.status.success(),
+        "git push failed: {}",
+        String::from_utf8_lossy(&push.stderr)
+    );
+
+    let _ = fs::remove_dir_all(&worktree);
+}
+
+fn push_commit_to_existing_branch(
+    repo_path: &Path,
+    branch: &str,
+    author_name: &str,
+    author_email: &str,
+    file_name: &str,
+    content: &str,
+) {
+    let unique = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("clock")
+        .as_nanos();
+    let worktree = std::env::temp_dir().join(format!("gitr-base-work-{branch}-{unique}"));
+    let _ = fs::remove_dir_all(&worktree);
+
+    let clone = Command::new("git")
+        .arg("clone")
+        .arg(repo_path)
+        .arg(&worktree)
+        .output()
+        .expect("clone repo");
+    assert!(
+        clone.status.success(),
+        "git clone failed: {}",
+        String::from_utf8_lossy(&clone.stderr)
+    );
+
+    let checkout = Command::new("git")
+        .current_dir(&worktree)
+        .arg("checkout")
+        .arg(branch)
+        .output()
+        .expect("checkout branch");
+    assert!(
+        checkout.status.success(),
+        "git checkout failed: {}",
+        String::from_utf8_lossy(&checkout.stderr)
+    );
+
+    fs::write(worktree.join(file_name), content).expect("write test file");
+
+    let add = Command::new("git")
+        .current_dir(&worktree)
+        .arg("add")
+        .arg(file_name)
+        .output()
+        .expect("git add");
+    assert!(
+        add.status.success(),
+        "git add failed: {}",
+        String::from_utf8_lossy(&add.stderr)
+    );
+
+    let commit = Command::new("git")
+        .current_dir(&worktree)
+        .env("GIT_AUTHOR_NAME", author_name)
+        .env("GIT_AUTHOR_EMAIL", author_email)
+        .env("GIT_COMMITTER_NAME", author_name)
+        .env("GIT_COMMITTER_EMAIL", author_email)
+        .arg("commit")
+        .arg("-m")
+        .arg(format!("Add {file_name}"))
+        .output()
+        .expect("git commit");
+    assert!(
+        commit.status.success(),
+        "git commit failed: {}",
+        String::from_utf8_lossy(&commit.stderr)
+    );
+
+    let push = Command::new("git")
+        .current_dir(&worktree)
+        .arg("push")
+        .arg("origin")
+        .arg(format!("HEAD:refs/heads/{branch}"))
+        .output()
+        .expect("git push");
+    assert!(
+        push.status.success(),
+        "git push failed: {}",
+        String::from_utf8_lossy(&push.stderr)
+    );
+
+    let _ = fs::remove_dir_all(&worktree);
+}
+
+struct TestEnv {
+    root: PathBuf,
+}
+
+impl TestEnv {
+    fn new(label: &str) -> Self {
+        let unique = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .expect("clock")
+            .as_nanos();
+        let root = std::env::temp_dir().join(format!("gitr-test-{label}-{unique}"));
+        fs::create_dir_all(&root).expect("create temp root");
+        Self { root }
+    }
+
+    async fn app(
+        &self,
+    ) -> impl Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error> {
+        self.app_with_git_binary("git").await
+    }
+
+    async fn app_with_git_binary(
+        &self,
+        git_binary: &str,
+    ) -> impl Service<Request, Response = ServiceResponse<BoxBody>, Error = actix_web::Error> {
+        let database_path = self.root.join("data").join("gitr.db");
+        let repository_root = self.root.join("data").join("repositories");
+        fs::create_dir_all(&repository_root).expect("repo root");
+
+        let config = AppConfig {
+            server: ServerConfig {
+                bind: "127.0.0.1:0".to_string(),
+                external_url: "http://127.0.0.1:3000/".to_string(),
+            },
+            database: DatabaseConfig {
+                path: database_path,
+            },
+            repository: RepositoryConfig {
+                root: repository_root,
+                default_branch: "main".to_string(),
+                git_binary: git_binary.to_string(),
+            },
+            app: CoreAppConfig {
+                run_user: "git".to_string(),
+            },
+        };
+
+        config.prepare().expect("prepare config");
+        let db = Database::open(&config.database.path).expect("open db");
+        db.init_schema().expect("init schema");
+
+        test::init_service(App::new().service(build_scope(Arc::new(AppState::new(config, db)))))
+            .await
+    }
+
+    fn repo_path(&self, owner: &str, repo: &str) -> PathBuf {
+        self.root
+            .join("data")
+            .join("repositories")
+            .join(owner)
+            .join(format!("{repo}.git"))
+    }
+}
+
+impl Drop for TestEnv {
+    fn drop(&mut self) {
+        let _ = fs::remove_dir_all(&self.root);
+    }
+}