a73x

58e76474

Initial implementation of git-collab: distributed issues and code review over git

a73x   2026-03-20 18:48

Event-sourced issue and patch tracking stored as per-entity DAGs in
refs/collab/{issues,patches}/<id>. Supports concurrent edits with
automatic merge reconciliation on sync. Includes CLI for issue/patch
lifecycle management and multi-remote sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..d5a2cb5
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1170 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
 "libc",
]

[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"

[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
 "utf8parse",
]

[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
 "windows-sys",
]

[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
 "once_cell_polyfill",
 "windows-sys",
]

[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"

[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"

[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"

[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"

[[package]]
name = "cc"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
 "find-msvc-tools",
 "jobserver",
 "libc",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"

[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
 "iana-time-zone",
 "js-sys",
 "num-traits",
 "serde",
 "wasm-bindgen",
 "windows-link",
]

[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
 "clap_builder",
 "clap_derive",
]

[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
 "anstream",
 "anstyle",
 "clap_lex",
 "strsim",
]

[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"

[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"

[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"

[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"

[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
 "libc",
 "windows-sys",
]

[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"

[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"

[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"

[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
 "percent-encoding",
]

[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
 "cfg-if",
 "libc",
 "r-efi 5.3.0",
 "wasip2",
]

[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
 "cfg-if",
 "libc",
 "r-efi 6.0.0",
 "wasip2",
 "wasip3",
]

[[package]]
name = "git-collab"
version = "0.1.0"
dependencies = [
 "chrono",
 "clap",
 "git2",
 "serde",
 "serde_json",
 "tempfile",
]

[[package]]
name = "git2"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
dependencies = [
 "bitflags",
 "libc",
 "libgit2-sys",
 "log",
 "openssl-probe",
 "openssl-sys",
 "url",
]

[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
 "foldhash",
]

[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"

[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
 "android_system_properties",
 "core-foundation-sys",
 "iana-time-zone-haiku",
 "js-sys",
 "log",
 "wasm-bindgen",
 "windows-core",
]

[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
 "cc",
]

[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
 "displaydoc",
 "potential_utf",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
 "displaydoc",
 "litemap",
 "tinystr",
 "writeable",
 "zerovec",
]

[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
 "icu_collections",
 "icu_normalizer_data",
 "icu_properties",
 "icu_provider",
 "smallvec",
 "zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"

[[package]]
name = "icu_properties"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
 "icu_collections",
 "icu_locale_core",
 "icu_properties_data",
 "icu_provider",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "icu_properties_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"

[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
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 = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
 "equivalent",
 "hashbrown 0.16.1",
 "serde",
 "serde_core",
]

[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
 "getrandom 0.3.4",
 "libc",
]

[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
 "once_cell",
 "wasm-bindgen",
]

[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"

[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"

[[package]]
name = "libgit2-sys"
version = "0.17.0+1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
dependencies = [
 "cc",
 "libc",
 "libssh2-sys",
 "libz-sys",
 "openssl-sys",
 "pkg-config",
]

[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
 "cc",
 "libc",
 "libz-sys",
 "openssl-sys",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "libz-sys"
version = "1.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
dependencies = [
 "cc",
 "libc",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"

[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"

[[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 = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
 "autocfg",
]

[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"

[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"

[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
 "cc",
 "libc",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"

[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
 "zerovec",
]

[[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 = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
 "bitflags",
 "errno",
 "libc",
 "linux-raw-sys",
 "windows-sys",
]

[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"

[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"

[[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 = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"

[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"

[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
 "fastrand",
 "getrandom 0.4.2",
 "once_cell",
 "rustix",
 "windows-sys",
]

[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
 "displaydoc",
 "zerovec",
]

[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"

[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
 "form_urlencoded",
 "idna",
 "percent-encoding",
 "serde",
]

[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"

[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
 "wit-bindgen",
]

[[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",
]

[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
 "bumpalo",
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
 "leb128fmt",
 "wasmparser",
]

[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
 "anyhow",
 "indexmap",
 "wasm-encoder",
 "wasmparser",
]

[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
 "bitflags",
 "hashbrown 0.15.5",
 "indexmap",
 "semver",
]

[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
 "windows-implement",
 "windows-interface",
 "windows-link",
 "windows-result",
 "windows-strings",
]

[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
 "windows-link",
]

[[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-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.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"

[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
 "displaydoc",
 "yoke",
 "zerofrom",
]

[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
 "yoke",
 "zerofrom",
 "zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..c2d0704
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
[package]
name = "git-collab"
version = "0.1.0"
edition = "2021"

[dependencies]
git2 = "0.19"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }

[dev-dependencies]
tempfile = "3"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9713f6a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
CARGO   := cargo
PROFILE ?= debug
BIN     := git-collab
PREFIX  ?= /usr/local

# --- Building blocks ---
.PHONY: fmt check clippy test build install clean

fmt:
	$(CARGO) fmt

check:
	$(CARGO) check

clippy:
	$(CARGO) clippy -- -D warnings

test:
	$(CARGO) test

build:
	$(CARGO) build $(if $(filter release,$(PROFILE)),--release,)

# --- Composites ---
.PHONY: lint ci

lint: fmt check clippy

ci: lint test build

# --- Install ---
install: PROFILE := release
install: build
	install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN)

uninstall:
	rm -f $(PREFIX)/bin/$(BIN)

clean:
	$(CARGO) clean

# --- Dev helpers ---
.PHONY: run dev

run:
	$(CARGO) run -- $(ARGS)

dev: fmt
	$(CARGO) run -- $(ARGS)
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..c99da78
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,137 @@
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(
    name = "git-collab",
    about = "Distributed issues and code review over Git"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand)]
pub enum Commands {
    /// Initialize collab refspecs on all remotes
    Init,

    /// Manage issues
    #[command(subcommand)]
    Issue(IssueCmd),

    /// Manage patches (code review)
    #[command(subcommand)]
    Patch(PatchCmd),

    /// Sync with a remote (fetch, reconcile, push)
    Sync {
        /// Remote name (default: origin)
        #[arg(default_value = "origin")]
        remote: String,
    },
}

#[derive(Subcommand)]
pub enum IssueCmd {
    /// Open a new issue
    Open {
        /// Issue title
        #[arg(short, long)]
        title: String,
        /// Issue body
        #[arg(short, long, default_value = "")]
        body: String,
    },
    /// List issues
    List {
        /// Show closed issues too
        #[arg(short = 'a', long)]
        all: bool,
    },
    /// Show issue details
    Show {
        /// Issue ID (prefix match)
        id: String,
    },
    /// Comment on an issue
    Comment {
        /// Issue ID (prefix match)
        id: String,
        /// Comment body
        #[arg(short, long)]
        body: String,
    },
    /// Close an issue
    Close {
        /// Issue ID (prefix match)
        id: String,
        /// Reason for closing
        #[arg(short, long)]
        reason: Option<String>,
    },
    /// Reopen a closed issue
    Reopen {
        /// Issue ID (prefix match)
        id: String,
    },
}

#[derive(Subcommand)]
pub enum PatchCmd {
    /// Create a new patch for review
    Create {
        /// Patch title
        #[arg(short, long)]
        title: String,
        /// Patch description
        #[arg(short, long, default_value = "")]
        body: String,
        /// Base branch ref
        #[arg(long, default_value = "main")]
        base: String,
        /// Head commit to review
        #[arg(long)]
        head: String,
    },
    /// List patches
    List {
        /// Show closed/merged patches too
        #[arg(short = 'a', long)]
        all: bool,
    },
    /// Show patch details
    Show {
        /// Patch ID (prefix match)
        id: String,
    },
    /// Review a patch
    Review {
        /// Patch ID (prefix match)
        id: String,
        /// Verdict: approve, request-changes, comment
        #[arg(short, long)]
        verdict: String,
        /// Review body
        #[arg(short, long)]
        body: String,
    },
    /// Revise a patch with a new head commit
    Revise {
        /// Patch ID (prefix match)
        id: String,
        /// New head commit
        #[arg(long)]
        head: String,
        /// Updated description
        #[arg(short, long)]
        body: Option<String>,
    },
    /// Close a patch
    Close {
        /// Patch ID (prefix match)
        id: String,
        /// Reason for closing
        #[arg(short, long)]
        reason: Option<String>,
    },
}
diff --git a/src/dag.rs b/src/dag.rs
new file mode 100644
index 0000000..e9d4233
--- /dev/null
+++ b/src/dag.rs
@@ -0,0 +1,143 @@
use git2::{Oid, Repository, Sort};

use crate::event::{Action, Event};
use crate::identity::author_signature;

/// Create an orphan commit (no parents) with the given event.
/// Returns the new commit OID which also serves as the entity ID.
pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, git2::Error> {
    let json = serde_json::to_vec_pretty(event).expect("event serialization failed");
    let blob_oid = repo.blob(&json)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
    let message = commit_message(&event.action);

    let oid = repo.commit(None, &sig, &sig, &message, &tree, &[])?;
    Ok(oid)
}

/// Append an event to an existing DAG. The current tip is the parent.
pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, git2::Error> {
    let json = serde_json::to_vec_pretty(event).expect("event serialization failed");
    let blob_oid = repo.blob(&json)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
    let message = commit_message(&event.action);

    let parent_oid = repo.refname_to_id(ref_name)?;
    let parent = repo.find_commit(parent_oid)?;

    let oid = repo.commit(Some(ref_name), &sig, &sig, &message, &tree, &[&parent])?;
    Ok(oid)
}

/// Walk the DAG from the given ref in topological order (oldest first).
/// Returns (commit_oid, event) pairs.
pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)>, git2::Error> {
    let tip = repo.refname_to_id(ref_name)?;
    let mut revwalk = repo.revwalk()?;
    revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
    revwalk.push(tip)?;

    let mut events = Vec::new();
    for oid_result in revwalk {
        let oid = oid_result?;
        let commit = repo.find_commit(oid)?;
        let tree = commit.tree()?;
        let entry = tree
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
        let blob = repo.find_blob(entry.id())?;
        let event: Event =
            serde_json::from_slice(blob.content()).expect("invalid event.json in commit");
        events.push((oid, event));
    }
    Ok(events)
}

/// Reconcile a local ref with a remote ref. Returns the final tip OID.
///
/// - If they're the same: no-op
/// - If remote is ancestor of local: local is ahead, no-op
/// - If local is ancestor of remote: fast-forward
/// - Otherwise: create a merge commit
pub fn reconcile(
    repo: &Repository,
    local_ref: &str,
    remote_ref: &str,
    merge_author: &crate::event::Author,
) -> Result<Oid, git2::Error> {
    let local_oid = repo.refname_to_id(local_ref)?;
    let remote_oid = repo.refname_to_id(remote_ref)?;

    if local_oid == remote_oid {
        return Ok(local_oid);
    }

    let merge_base = repo.merge_base(local_oid, remote_oid)?;

    if merge_base == remote_oid {
        // Remote is ancestor of local — local is ahead
        return Ok(local_oid);
    }

    if merge_base == local_oid {
        // Local is ancestor of remote — fast-forward
        repo.reference(local_ref, remote_oid, true, "fast-forward reconcile")?;
        return Ok(remote_oid);
    }

    // True fork — create merge commit
    let merge_event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author: merge_author.clone(),
        action: Action::Merge,
    };

    let json = serde_json::to_vec_pretty(&merge_event).expect("event serialization failed");
    let blob_oid = repo.blob(&json)?;
    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(merge_author)?;
    let local_commit = repo.find_commit(local_oid)?;
    let remote_commit = repo.find_commit(remote_oid)?;

    let oid = repo.commit(
        Some(local_ref),
        &sig,
        &sig,
        "collab: reconcile merge",
        &tree,
        &[&local_commit, &remote_commit],
    )?;

    Ok(oid)
}

fn commit_message(action: &Action) -> String {
    match action {
        Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title),
        Action::IssueComment { .. } => "issue: comment".to_string(),
        Action::IssueClose { .. } => "issue: close".to_string(),
        Action::IssueReopen => "issue: reopen".to_string(),
        Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title),
        Action::PatchRevise { .. } => "patch: revise".to_string(),
        Action::PatchReview { verdict, .. } => format!("patch: review ({:?})", verdict),
        Action::PatchComment { .. } => "patch: comment".to_string(),
        Action::PatchClose { .. } => "patch: close".to_string(),
        Action::Merge => "collab: merge".to_string(),
    }
}
diff --git a/src/event.rs b/src/event.rs
new file mode 100644
index 0000000..f7ba3a3
--- /dev/null
+++ b/src/event.rs
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
    pub name: String,
    pub email: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub timestamp: String,
    pub author: Author,
    pub action: Action,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
    IssueOpen {
        title: String,
        body: String,
    },
    IssueComment {
        body: String,
    },
    IssueClose {
        reason: Option<String>,
    },
    IssueReopen,
    PatchCreate {
        title: String,
        body: String,
        base_ref: String,
        head_commit: String,
    },
    PatchRevise {
        body: Option<String>,
        head_commit: String,
    },
    PatchReview {
        verdict: ReviewVerdict,
        body: String,
    },
    PatchComment {
        body: String,
    },
    PatchClose {
        reason: Option<String>,
    },
    Merge,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ReviewVerdict {
    Approve,
    RequestChanges,
    Comment,
}
diff --git a/src/identity.rs b/src/identity.rs
new file mode 100644
index 0000000..1e40fc3
--- /dev/null
+++ b/src/identity.rs
@@ -0,0 +1,14 @@
use git2::Repository;

use crate::event::Author;

pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> {
    let config = repo.config()?;
    let name = config.get_string("user.name")?;
    let email = config.get_string("user.email")?;
    Ok(Author { name, email })
}

pub fn author_signature(author: &Author) -> Result<git2::Signature<'static>, git2::Error> {
    git2::Signature::now(&author.name, &author.email)
}
diff --git a/src/issue.rs b/src/issue.rs
new file mode 100644
index 0000000..ccf68cf
--- /dev/null
+++ b/src/issue.rs
@@ -0,0 +1,115 @@
use git2::Repository;

use crate::dag;
use crate::event::{Action, Event};
use crate::identity::get_author;
use crate::state::{self, IssueStatus};

pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, git2::Error> {
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueOpen {
            title: title.to_string(),
            body: body.to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event)?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "issue open")?;
    Ok(id)
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> {
    let issues = state::list_issues(repo)?;
    let filtered: Vec<_> = issues
        .iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
        .collect();

    if filtered.is_empty() {
        println!("No issues found.");
        return Ok(());
    }

    for issue in &filtered {
        let status = match issue.status {
            IssueStatus::Open => "open",
            IssueStatus::Closed => "closed",
        };
        println!(
            "{:.8}  {:6}  {}  (by {})",
            issue.id, status, issue.title, issue.author.name
        );
    }
    Ok(())
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let issue = state::IssueState::from_ref(repo, &ref_name, &id)?;

    let status = match issue.status {
        IssueStatus::Open => "open",
        IssueStatus::Closed => "closed",
    };
    println!("Issue {} [{}]", &issue.id[..8], status);
    println!("Title: {}", issue.title);
    println!("Author: {} <{}>", issue.author.name, issue.author.email);
    println!("Created: {}", issue.created_at);
    if !issue.body.is_empty() {
        println!("\n{}", issue.body);
    }
    if !issue.comments.is_empty() {
        println!("\n--- Comments ---");
        for c in &issue.comments {
            println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
        }
    }
    Ok(())
}

pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Comment added.");
    Ok(())
}

pub fn close(repo: &Repository, id_prefix: &str, reason: Option<&str>) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueClose {
            reason: reason.map(|s| s.to_string()),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Issue closed.");
    Ok(())
}

pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueReopen,
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Issue reopened.");
    Ok(())
}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..0e99bd2
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,8 @@
pub mod cli;
pub mod dag;
pub mod event;
pub mod identity;
pub mod issue;
pub mod patch;
pub mod state;
pub mod sync;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8ba155a
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,75 @@
mod cli;
mod dag;
mod event;
mod identity;
mod issue;
mod patch;
mod state;
mod sync;

use clap::Parser;
use cli::{Cli, Commands, IssueCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;

fn main() {
    let cli = Cli::parse();

    let result = run(cli);
    if let Err(e) = result {
        eprintln!("error: {}", e);
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> Result<(), git2::Error> {
    let repo = Repository::open_from_env()?;

    match cli.command {
        Commands::Init => sync::init(&repo),
        Commands::Issue(cmd) => match cmd {
            IssueCmd::Open { title, body } => {
                let id = issue::open(&repo, &title, &body)?;
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all } => issue::list(&repo, all),
            IssueCmd::Show { id } => issue::show(&repo, &id),
            IssueCmd::Comment { id, body } => issue::comment(&repo, &id, &body),
            IssueCmd::Close { id, reason } => issue::close(&repo, &id, reason.as_deref()),
            IssueCmd::Reopen { id } => issue::reopen(&repo, &id),
        },
        Commands::Patch(cmd) => match cmd {
            PatchCmd::Create {
                title,
                body,
                base,
                head,
            } => {
                let id = patch::create(&repo, &title, &body, &base, &head)?;
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all } => patch::list(&repo, all),
            PatchCmd::Show { id } => patch::show(&repo, &id),
            PatchCmd::Review { id, verdict, body } => {
                let v = match verdict.as_str() {
                    "approve" => ReviewVerdict::Approve,
                    "request-changes" => ReviewVerdict::RequestChanges,
                    "comment" => ReviewVerdict::Comment,
                    _ => {
                        return Err(git2::Error::from_str(
                            "verdict must be: approve, request-changes, or comment",
                        ));
                    }
                };
                patch::review(&repo, &id, v, &body)
            }
            PatchCmd::Revise { id, head, body } => {
                patch::revise(&repo, &id, &head, body.as_deref())
            }
            PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()),
        },
        Commands::Sync { remote } => sync::sync(&repo, &remote),
    }
}
diff --git a/src/patch.rs b/src/patch.rs
new file mode 100644
index 0000000..1f47c95
--- /dev/null
+++ b/src/patch.rs
@@ -0,0 +1,149 @@
use git2::Repository;

use crate::dag;
use crate::event::{Action, Event, ReviewVerdict};
use crate::identity::get_author;
use crate::state::{self, PatchStatus};

pub fn create(
    repo: &Repository,
    title: &str,
    body: &str,
    base_ref: &str,
    head_commit: &str,
) -> Result<String, git2::Error> {
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::PatchCreate {
            title: title.to_string(),
            body: body.to_string(),
            base_ref: base_ref.to_string(),
            head_commit: head_commit.to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event)?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "patch create")?;
    Ok(id)
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> {
    let patches = state::list_patches(repo)?;
    let filtered: Vec<_> = patches
        .iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
        .collect();

    if filtered.is_empty() {
        println!("No patches found.");
        return Ok(());
    }

    for p in &filtered {
        let status = match p.status {
            PatchStatus::Open => "open",
            PatchStatus::Closed => "closed",
            PatchStatus::Merged => "merged",
        };
        println!(
            "{:.8}  {:6}  {}  (by {})",
            p.id, status, p.title, p.author.name
        );
    }
    Ok(())
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = state::PatchState::from_ref(repo, &ref_name, &id)?;

    let status = match p.status {
        PatchStatus::Open => "open",
        PatchStatus::Closed => "closed",
        PatchStatus::Merged => "merged",
    };
    println!("Patch {} [{}]", &p.id[..8], status);
    println!("Title: {}", p.title);
    println!("Author: {} <{}>", p.author.name, p.author.email);
    println!("Base: {}  Head: {:.8}", p.base_ref, p.head_commit);
    println!("Created: {}", p.created_at);
    if !p.body.is_empty() {
        println!("\n{}", p.body);
    }
    if !p.reviews.is_empty() {
        println!("\n--- Reviews ---");
        for r in &p.reviews {
            println!(
                "\n{} ({:?}) - {}:\n{}",
                r.author.name, r.verdict, r.timestamp, r.body
            );
        }
    }
    if !p.comments.is_empty() {
        println!("\n--- Comments ---");
        for c in &p.comments {
            println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
        }
    }
    Ok(())
}

pub fn review(
    repo: &Repository,
    id_prefix: &str,
    verdict: ReviewVerdict,
    body: &str,
) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::PatchReview {
            verdict,
            body: body.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Review submitted.");
    Ok(())
}

pub fn revise(
    repo: &Repository,
    id_prefix: &str,
    head_commit: &str,
    body: Option<&str>,
) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::PatchRevise {
            body: body.map(|s| s.to_string()),
            head_commit: head_commit.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Patch revised.");
    Ok(())
}

pub fn close(repo: &Repository, id_prefix: &str, reason: Option<&str>) -> Result<(), git2::Error> {
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::PatchClose {
            reason: reason.map(|s| s.to_string()),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Patch closed.");
    Ok(())
}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..f047f04
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,271 @@
use git2::{Oid, Repository};

use crate::dag;
use crate::event::{Action, Author, ReviewVerdict};

#[derive(Debug, Clone, PartialEq)]
pub enum IssueStatus {
    Open,
    Closed,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Comment {
    pub author: Author,
    pub body: String,
    pub timestamp: String,
    pub commit_id: Oid,
}

#[derive(Debug, Clone)]
pub struct IssueState {
    pub id: String,
    pub title: String,
    pub body: String,
    pub status: IssueStatus,
    pub comments: Vec<Comment>,
    pub created_at: String,
    pub author: Author,
}

#[derive(Debug, Clone)]
pub struct Review {
    pub author: Author,
    pub verdict: ReviewVerdict,
    pub body: String,
    pub timestamp: String,
}

#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum PatchStatus {
    Open,
    Closed,
    Merged,
}

#[derive(Debug, Clone)]
pub struct PatchState {
    pub id: String,
    pub title: String,
    pub body: String,
    pub status: PatchStatus,
    pub base_ref: String,
    pub head_commit: String,
    pub comments: Vec<Comment>,
    pub reviews: Vec<Review>,
    pub created_at: String,
    pub author: Author,
}

impl IssueState {
    pub fn from_ref(repo: &Repository, ref_name: &str, id: &str) -> Result<Self, git2::Error> {
        let events = dag::walk_events(repo, ref_name)?;
        let mut state: Option<IssueState> = None;

        for (oid, event) in events {
            match event.action {
                Action::IssueOpen { title, body } => {
                    state = Some(IssueState {
                        id: id.to_string(),
                        title,
                        body,
                        status: IssueStatus::Open,
                        comments: Vec::new(),
                        created_at: event.timestamp.clone(),
                        author: event.author.clone(),
                    });
                }
                Action::IssueComment { body } => {
                    if let Some(ref mut s) = state {
                        s.comments.push(Comment {
                            author: event.author.clone(),
                            body,
                            timestamp: event.timestamp.clone(),
                            commit_id: oid,
                        });
                    }
                }
                Action::IssueClose { .. } => {
                    if let Some(ref mut s) = state {
                        s.status = IssueStatus::Closed;
                    }
                }
                Action::IssueReopen => {
                    if let Some(ref mut s) = state {
                        s.status = IssueStatus::Open;
                    }
                }
                Action::Merge => {}
                _ => {}
            }
        }

        state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG"))
    }
}

impl PatchState {
    pub fn from_ref(repo: &Repository, ref_name: &str, id: &str) -> Result<Self, git2::Error> {
        let events = dag::walk_events(repo, ref_name)?;
        let mut state: Option<PatchState> = None;

        for (oid, event) in events {
            match event.action {
                Action::PatchCreate {
                    title,
                    body,
                    base_ref,
                    head_commit,
                } => {
                    state = Some(PatchState {
                        id: id.to_string(),
                        title,
                        body,
                        status: PatchStatus::Open,
                        base_ref,
                        head_commit,
                        comments: Vec::new(),
                        reviews: Vec::new(),
                        created_at: event.timestamp.clone(),
                        author: event.author.clone(),
                    });
                }
                Action::PatchRevise { body, head_commit } => {
                    if let Some(ref mut s) = state {
                        s.head_commit = head_commit;
                        if let Some(b) = body {
                            s.body = b;
                        }
                    }
                }
                Action::PatchReview { verdict, body } => {
                    if let Some(ref mut s) = state {
                        s.reviews.push(Review {
                            author: event.author.clone(),
                            verdict,
                            body,
                            timestamp: event.timestamp.clone(),
                        });
                    }
                }
                Action::PatchComment { body } => {
                    if let Some(ref mut s) = state {
                        s.comments.push(Comment {
                            author: event.author.clone(),
                            body,
                            timestamp: event.timestamp.clone(),
                            commit_id: oid,
                        });
                    }
                }
                Action::PatchClose { .. } => {
                    if let Some(ref mut s) = state {
                        s.status = PatchStatus::Closed;
                    }
                }
                Action::Merge => {}
                _ => {}
            }
        }

        state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG"))
    }
}

/// List all issue refs and return their materialized state.
pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, git2::Error> {
    let mut issues = Vec::new();
    let refs = repo.references_glob("refs/collab/issues/*")?;
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/issues/")
            .unwrap_or_default()
            .to_string();
        match IssueState::from_ref(repo, &ref_name, &id) {
            Ok(state) => issues.push(state),
            Err(_) => continue,
        }
    }
    Ok(issues)
}

/// List all patch refs and return their materialized state.
pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, git2::Error> {
    let mut patches = Vec::new();
    let refs = repo.references_glob("refs/collab/patches/*")?;
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/patches/")
            .unwrap_or_default()
            .to_string();
        match PatchState::from_ref(repo, &ref_name, &id) {
            Ok(state) => patches.push(state),
            Err(_) => continue,
        }
    }
    Ok(patches)
}

/// Resolve a short ID prefix to the full ref name. Returns (ref_name, id).
pub fn resolve_issue_ref(repo: &Repository, prefix: &str) -> Result<(String, String), git2::Error> {
    let refs = repo.references_glob("refs/collab/issues/*")?;
    let mut matches = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/issues/")
            .unwrap_or_default()
            .to_string();
        if id.starts_with(prefix) {
            matches.push((ref_name, id));
        }
    }
    match matches.len() {
        0 => Err(git2::Error::from_str(&format!(
            "no issue found matching '{}'",
            prefix
        ))),
        1 => Ok(matches.into_iter().next().unwrap()),
        _ => Err(git2::Error::from_str(&format!(
            "ambiguous issue prefix '{}': {} matches",
            prefix,
            matches.len()
        ))),
    }
}

/// Resolve a short ID prefix to the full patch ref name.
pub fn resolve_patch_ref(repo: &Repository, prefix: &str) -> Result<(String, String), git2::Error> {
    let refs = repo.references_glob("refs/collab/patches/*")?;
    let mut matches = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/patches/")
            .unwrap_or_default()
            .to_string();
        if id.starts_with(prefix) {
            matches.push((ref_name, id));
        }
    }
    match matches.len() {
        0 => Err(git2::Error::from_str(&format!(
            "no patch found matching '{}'",
            prefix
        ))),
        1 => Ok(matches.into_iter().next().unwrap()),
        _ => Err(git2::Error::from_str(&format!(
            "ambiguous patch prefix '{}': {} matches",
            prefix,
            matches.len()
        ))),
    }
}
diff --git a/src/sync.rs b/src/sync.rs
new file mode 100644
index 0000000..b51d0c8
--- /dev/null
+++ b/src/sync.rs
@@ -0,0 +1,153 @@
use git2::{Direction, Oid, Repository};

use crate::dag;
use crate::identity::get_author;

/// Add collab refspecs to all remotes.
pub fn init(repo: &Repository) -> Result<(), git2::Error> {
    let remotes = repo.remotes()?;
    if remotes.is_empty() {
        println!("No remotes configured.");
        return Ok(());
    }
    for remote_name in remotes.iter().flatten() {
        // Add fetch refspec for collab refs
        let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name);
        repo.remote_add_fetch(remote_name, &fetch_spec)?;
        println!("Configured remote '{}'", remote_name);
    }
    println!("Collab refspecs initialized.");
    Ok(())
}

/// Sync with a specific remote: fetch, reconcile, push.
pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), git2::Error> {
    let author = get_author(repo)?;

    // Step 1: Connect to remote and discover collab refs
    println!("Fetching from '{}'...", remote_name);
    let mut remote = repo.find_remote(remote_name)?;

    // Fetch using configured refspecs (empty slice = use config refspecs)
    let empty: &[&str] = &[];
    remote.fetch(empty, None, None)?;
    drop(remote);

    // After fetch, check if the refspec-mapped sync refs exist.
    // If git2's fetch didn't map them, manually discover and create them.
    // We do this by connecting and listing remote refs.
    let remote_collab_refs = list_remote_collab_refs(repo, remote_name)?;
    for (remote_ref_name, oid) in &remote_collab_refs {
        // Map refs/collab/X to refs/collab/sync/{remote}/X
        let suffix = remote_ref_name
            .strip_prefix("refs/collab/")
            .unwrap_or(remote_ref_name);
        let sync_ref = format!("refs/collab/sync/{}/{}", remote_name, suffix);

        // Ensure the object exists locally (it should after fetch)
        if repo.find_commit(*oid).is_ok() {
            repo.reference(&sync_ref, *oid, true, "sync: map remote ref")?;
        }
    }

    // Step 2: Reconcile issues
    reconcile_refs(repo, remote_name, "issues", &author)?;

    // Step 3: Reconcile patches
    reconcile_refs(repo, remote_name, "patches", &author)?;

    // Step 4: Push — enumerate concrete refs (git2 push doesn't support globs)
    println!("Pushing to '{}'...", remote_name);
    let mut push_specs: Vec<String> = Vec::new();

    for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
        let refs = repo.references_glob(pattern)?;
        for r in refs {
            let r = r?;
            if let Some(name) = r.name() {
                push_specs.push(format!("+{}:{}", name, name));
            }
        }
    }

    if !push_specs.is_empty() {
        let mut remote = repo.find_remote(remote_name)?;
        let specs: Vec<&str> = push_specs.iter().map(|s| s.as_str()).collect();
        remote.push(&specs, None)?;
    }

    // Step 5: Clean up sync refs
    for prefix in &[
        format!("refs/collab/sync/{}/issues/", remote_name),
        format!("refs/collab/sync/{}/patches/", remote_name),
    ] {
        let refs: Vec<String> = repo
            .references_glob(&format!("{}*", prefix))?
            .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
            .collect();
        for ref_name in refs {
            let mut r = repo.find_reference(&ref_name)?;
            r.delete()?;
        }
    }

    println!("Sync complete.");
    Ok(())
}

/// Connect to a remote and list all refs under refs/collab/ (issues + patches only).
fn list_remote_collab_refs(
    repo: &Repository,
    remote_name: &str,
) -> Result<Vec<(String, Oid)>, git2::Error> {
    let mut remote = repo.find_remote(remote_name)?;
    remote.connect(Direction::Fetch)?;

    let refs: Vec<(String, Oid)> = remote
        .list()?
        .iter()
        .filter(|head| {
            let name = head.name();
            name.starts_with("refs/collab/issues/") || name.starts_with("refs/collab/patches/")
        })
        .map(|head| (head.name().to_string(), head.oid()))
        .collect();

    remote.disconnect()?;
    Ok(refs)
}

/// Reconcile all refs of a given kind (issues or patches) from sync refs.
fn reconcile_refs(
    repo: &Repository,
    remote_name: &str,
    kind: &str,
    author: &crate::event::Author,
) -> Result<(), git2::Error> {
    let sync_prefix = format!("refs/collab/sync/{}/{}/", remote_name, kind);
    let sync_refs: Vec<(String, String)> = {
        let refs = repo.references_glob(&format!("{}*", sync_prefix))?;
        refs.filter_map(|r| {
            let r = r.ok()?;
            let name = r.name()?.to_string();
            let id = name.strip_prefix(&sync_prefix)?.to_string();
            Some((name, id))
        })
        .collect()
    };

    for (remote_ref, id) in &sync_refs {
        let local_ref = format!("refs/collab/{}/{}", kind, id);
        if repo.refname_to_id(&local_ref).is_ok() {
            match dag::reconcile(repo, &local_ref, remote_ref, author) {
                Ok(_) => println!("  Reconciled {} {:.8}", kind, id),
                Err(e) => eprintln!("  Failed to reconcile {} {:.8}: {}", kind, id, e),
            }
        } else {
            let oid = repo.refname_to_id(remote_ref)?;
            repo.reference(&local_ref, oid, false, "sync: new from remote")?;
            println!("  New {} {:.8} from remote", kind, id);
        }
    }
    Ok(())
}
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
new file mode 100644
index 0000000..c7d31c9
--- /dev/null
+++ b/tests/collab_test.rs
@@ -0,0 +1,601 @@
use git2::{Repository, Sort};
use std::path::Path;
use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Create a bare repo in a tempdir and configure user identity.
fn init_repo(dir: &Path, author: &Author) -> Repository {
    let repo = Repository::init(dir).expect("init repo");
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", &author.name).unwrap();
        config.set_str("user.email", &author.email).unwrap();
    }
    repo
}

/// Open an issue directly using DAG primitives (for fine-grained control in tests).
fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();
    (ref_name, id)
}

/// Append a comment event to a ref.
fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

/// Append a close event to a ref.
fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

/// Append a reopen event to a ref.
fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueReopen,
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

// ---------------------------------------------------------------------------
// Basic DAG tests
// ---------------------------------------------------------------------------

#[test]
fn test_create_and_walk_issue_dag() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "First issue");
    add_comment(&repo, &ref_name, &alice(), "A comment");

    let events = dag::walk_events(&repo, &ref_name).unwrap();
    assert_eq!(events.len(), 2);
    assert!(matches!(events[0].1.action, Action::IssueOpen { .. }));
    assert!(matches!(events[1].1.action, Action::IssueComment { .. }));

    // State materializes correctly
    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.title, "First issue");
    assert_eq!(state.status, IssueStatus::Open);
    assert_eq!(state.comments.len(), 1);
}

#[test]
fn test_issue_lifecycle() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Bug report");
    add_comment(&repo, &ref_name, &bob(), "I can reproduce");
    close_issue(&repo, &ref_name, &alice());

    let s = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(s.status, IssueStatus::Closed);
    assert_eq!(s.comments.len(), 1);

    // Reopen
    reopen_issue(&repo, &ref_name, &bob());
    let s = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(s.status, IssueStatus::Open);
}

#[test]
fn test_list_issues_filters_by_status() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref1, _) = open_issue(&repo, &alice(), "Open issue");
    let (ref2, _) = open_issue(&repo, &alice(), "Closed issue");
    close_issue(&repo, &ref2, &alice());

    let all = state::list_issues(&repo).unwrap();
    assert_eq!(all.len(), 2);

    let open: Vec<_> = all
        .iter()
        .filter(|i| i.status == IssueStatus::Open)
        .collect();
    assert_eq!(open.len(), 1);
    assert_eq!(open[0].title, "Open issue");
}

// ---------------------------------------------------------------------------
// Multi-user collaboration: concurrent edits → forked DAG → reconcile
// ---------------------------------------------------------------------------

#[test]
fn test_concurrent_comments_create_fork_and_reconcile() {
    // Simulate two users commenting on the same issue concurrently.
    //
    // Starting state:
    //   commit A (IssueOpen)
    //
    // Alice adds comment → commit B (parent: A)
    // Bob adds comment   → commit C (parent: A)  ← fork!
    //
    // Reconciliation creates merge commit M (parents: B, C)
    // State replay should see all 3 events (open + 2 comments)

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Create the issue (commit A)
    let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments (commit B) — advances the ref
    add_comment(&repo, &ref_name, &alice(), "Alice's comment");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Simulate Bob's concurrent comment: reset ref back to root, then append
    repo.reference(&ref_name, root_oid, true, "simulate bob fork")
        .unwrap();
    add_comment(&repo, &ref_name, &bob(), "Bob's comment");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Now we have two tips diverged from root_oid.
    // Put Bob's tip in a "remote" ref so we can reconcile.
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote tip")
        .unwrap();

    // Reset local ref to Alice's tip
    repo.reference(&ref_name, alice_tip, true, "restore alice tip")
        .unwrap();

    // Reconcile
    let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();

    // Verify merge commit has 2 parents
    let merge_commit = repo.find_commit(merge_oid).unwrap();
    assert_eq!(merge_commit.parent_count(), 2);

    // Walk events — should see open + both comments + merge
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect();

    // Must have the open event
    assert!(actions
        .iter()
        .any(|a| matches!(a, Action::IssueOpen { .. })));
    // Must have both comments
    let comments: Vec<_> = actions
        .iter()
        .filter(|a| matches!(a, Action::IssueComment { .. }))
        .collect();
    assert_eq!(comments.len(), 2, "both concurrent comments should appear");
    // Must have the merge
    assert!(actions.iter().any(|a| matches!(a, Action::Merge)));

    // State should show both comments
    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.comments.len(), 2);
    assert_eq!(state.status, IssueStatus::Open);
}

#[test]
fn test_concurrent_close_and_comment() {
    // Alice closes the issue while Bob comments on it concurrently.
    // After reconciliation, the issue should be closed (last event wins by topo order)
    // and Bob's comment should still be visible.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice closes
    close_issue(&repo, &ref_name, &alice());
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob comments (from root)
    repo.reference(&ref_name, root_oid, true, "bob fork")
        .unwrap();
    add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Set up for reconciliation
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote")
        .unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    // Both the close and comment should be in the DAG
    assert_eq!(state.comments.len(), 1);
    // The close event should have taken effect
    assert_eq!(state.status, IssueStatus::Closed);
}

#[test]
fn test_concurrent_close_and_reopen() {
    // Alice closes while Bob reopens from a previously-closed state.
    // This tests conflicting status transitions.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict");

    // First, close the issue so both users start from "closed"
    close_issue(&repo, &ref_name, &alice());
    let closed_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments on the closed issue
    add_comment(&repo, &ref_name, &alice(), "Staying closed");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob reopens from the closed state
    repo.reference(&ref_name, closed_oid, true, "bob fork")
        .unwrap();
    reopen_issue(&repo, &ref_name, &bob());
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote")
        .unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    // Both branches are replayed — the final status depends on topo order.
    // The important thing is that we don't crash and both events are present.
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    let has_close = events
        .iter()
        .any(|(_, e)| matches!(e.action, Action::IssueClose { .. }));
    let has_reopen = events
        .iter()
        .any(|(_, e)| matches!(e.action, Action::IssueReopen));
    assert!(has_close, "close event must be in DAG");
    assert!(has_reopen, "reopen event must be in DAG");
}

#[test]
fn test_fast_forward_reconcile() {
    // If local is behind remote (remote has strictly more events), reconcile
    // should fast-forward without creating a merge commit.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "FF test");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Add comment (advances ref)
    add_comment(&repo, &ref_name, &alice(), "Extra comment");
    let ahead_tip = repo.refname_to_id(&ref_name).unwrap();

    // Simulate: local is at root, remote is at ahead_tip
    repo.reference(&ref_name, root_oid, true, "reset local")
        .unwrap();
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, ahead_tip, true, "remote ahead")
        .unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    assert_eq!(result, ahead_tip, "should fast-forward to remote tip");

    // No merge commit — walk should have exactly 2 events
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    assert_eq!(events.len(), 2);
    assert!(!events
        .iter()
        .any(|(_, e)| matches!(e.action, Action::Merge)));
}

#[test]
fn test_no_op_when_already_in_sync() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Already synced");
    let tip = repo.refname_to_id(&ref_name).unwrap();

    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, tip, true, "same tip").unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    assert_eq!(result, tip);
}

#[test]
fn test_local_ahead_no_merge() {
    // If local has more events than remote, reconcile should be a no-op.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Local ahead");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    add_comment(&repo, &ref_name, &alice(), "Local only comment");
    let local_tip = repo.refname_to_id(&ref_name).unwrap();

    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, root_oid, true, "remote behind")
        .unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    assert_eq!(result, local_tip, "local should stay ahead");
}

// ---------------------------------------------------------------------------
// Patch collaboration tests
// ---------------------------------------------------------------------------

fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchCreate {
            title: title.to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "test patch").unwrap();
    (ref_name, id)
}

fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchReview {
            verdict,
            body: "review comment".to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

#[test]
fn test_patch_review_workflow() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X");

    // Bob reviews
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.reviews.len(), 1);
    assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges);

    // Alice revises
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchRevise {
            body: Some("Updated implementation".to_string()),
            head_commit: "def456".to_string(),
        },
    };
    dag::append_event(&repo, &ref_name, &event).unwrap();

    // Bob approves
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve);

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.reviews.len(), 2);
    assert_eq!(state.head_commit, "def456");
    assert_eq!(state.body, "Updated implementation");
}

#[test]
fn test_concurrent_reviews_on_patch() {
    // Alice and Bob both review the same patch concurrently.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice approves
    add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve);
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob requests changes (from root)
    repo.reference(&ref_name, root_oid, true, "bob fork")
        .unwrap();
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reconcile
    let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote")
        .unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.reviews.len(), 2, "both reviews should be present");
    assert_eq!(state.status, PatchStatus::Open);
}

// ---------------------------------------------------------------------------
// Three-way concurrent edits
// ---------------------------------------------------------------------------

#[test]
fn test_three_way_fork_sequential_reconcile() {
    // Three users all comment from the same base.
    // We reconcile them pairwise: first alice+bob, then result+charlie.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let charlie = Author {
        name: "Charlie".to_string(),
        email: "charlie@example.com".to_string(),
    };

    let (ref_name, id) = open_issue(&repo, &alice(), "Three-way");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments
    add_comment(&repo, &ref_name, &alice(), "Alice here");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob comments from root
    repo.reference(&ref_name, root_oid, true, "bob").unwrap();
    add_comment(&repo, &ref_name, &bob(), "Bob here");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Charlie comments from root
    repo.reference(&ref_name, root_oid, true, "charlie")
        .unwrap();
    add_comment(&repo, &ref_name, &charlie, "Charlie here");
    let charlie_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reconcile alice + bob
    repo.reference(&ref_name, alice_tip, true, "alice").unwrap();
    let bob_ref = "refs/collab/sync/origin/issues/bob_temp";
    repo.reference(bob_ref, bob_tip, true, "bob remote")
        .unwrap();
    dag::reconcile(&repo, &ref_name, bob_ref, &alice()).unwrap();

    // Reconcile result + charlie
    let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp";
    repo.reference(charlie_ref, charlie_tip, true, "charlie remote")
        .unwrap();
    dag::reconcile(&repo, &ref_name, charlie_ref, &alice()).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.comments.len(), 3, "all three comments must survive");

    let bodies: Vec<&str> = state.comments.iter().map(|c| c.body.as_str()).collect();
    assert!(bodies.contains(&"Alice here"));
    assert!(bodies.contains(&"Bob here"));
    assert!(bodies.contains(&"Charlie here"));
}

// ---------------------------------------------------------------------------
// Multiple issues don't interfere
// ---------------------------------------------------------------------------

#[test]
fn test_multiple_issues_independent() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref1, id1) = open_issue(&repo, &alice(), "Issue one");
    let (ref2, id2) = open_issue(&repo, &bob(), "Issue two");

    add_comment(&repo, &ref1, &bob(), "Comment on issue one");
    close_issue(&repo, &ref2, &bob());

    let s1 = IssueState::from_ref(&repo, &ref1, &id1).unwrap();
    let s2 = IssueState::from_ref(&repo, &ref2, &id2).unwrap();

    assert_eq!(s1.status, IssueStatus::Open);
    assert_eq!(s1.comments.len(), 1);
    assert_eq!(s2.status, IssueStatus::Closed);
    assert_eq!(s2.comments.len(), 0);
}

// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------

#[test]
fn test_empty_issue_body() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "No body");
    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.body, "");
}

#[test]
fn test_many_comments_performance() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Many comments");
    for i in 0..50 {
        add_comment(&repo, &ref_name, &alice(), &format!("Comment {}", i));
    }

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.comments.len(), 50);
}

#[test]
fn test_resolve_prefix_match() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (_, id) = open_issue(&repo, &alice(), "Prefix test");
    let short = &id[..8];

    let (resolved_ref, resolved_id) = state::resolve_issue_ref(&repo, short).unwrap();
    assert_eq!(resolved_id, id);
    assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id));
}
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
new file mode 100644
index 0000000..44cba29
--- /dev/null
+++ b/tests/sync_test.rs
@@ -0,0 +1,510 @@
//! End-to-end sync tests with two repos sharing a bare remote.
//!
//! Topology for every test:
//!
//!   bare_remote  <---push/fetch--->  alice_repo
//!                <---push/fetch--->  bob_repo

use std::path::Path;
use tempfile::TempDir;

use git2::Repository;
use git_collab::dag;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
use git_collab::sync;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Set up the standard test topology: bare remote + two clones.
/// Returns (bare_dir, alice_dir, bob_dir) — TempDirs that must be kept alive.
struct TestCluster {
    _bare_dir: TempDir,
    alice_dir: TempDir,
    bob_dir: TempDir,
}

impl TestCluster {
    fn new() -> Self {
        let bare_dir = TempDir::new().unwrap();
        let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();

        // Need at least one ref in the bare repo for clones to work,
        // so we create a dummy initial commit on refs/heads/main.
        {
            let sig = git2::Signature::now("init", "init@test").unwrap();
            let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
            let tree = bare_repo.find_tree(tree_oid).unwrap();
            bare_repo
                .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
                .unwrap();
        }
        drop(bare_repo);

        let alice_dir = TempDir::new().unwrap();
        let bob_dir = TempDir::new().unwrap();

        // Clone for Alice
        let alice_repo =
            Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap();
        {
            let mut config = alice_repo.config().unwrap();
            config.set_str("user.name", "Alice").unwrap();
            config.set_str("user.email", "alice@example.com").unwrap();
        }
        sync::init(&alice_repo).unwrap();

        // Clone for Bob
        let bob_repo =
            Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
        {
            let mut config = bob_repo.config().unwrap();
            config.set_str("user.name", "Bob").unwrap();
            config.set_str("user.email", "bob@example.com").unwrap();
        }
        sync::init(&bob_repo).unwrap();

        TestCluster {
            _bare_dir: bare_dir,
            alice_dir,
            bob_dir,
        }
    }

    fn alice_repo(&self) -> Repository {
        Repository::open(self.alice_dir.path()).unwrap()
    }

    fn bob_repo(&self) -> Repository {
        Repository::open(self.bob_dir.path()).unwrap()
    }
}

fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "open").unwrap();
    (ref_name, id)
}

fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn test_alice_creates_issue_bob_syncs_and_sees_it() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates an issue and syncs
    let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice");
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs and should see the issue
    sync::sync(&bob_repo, "origin").unwrap();

    let bob_ref = format!("refs/collab/issues/{}", id);
    let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
    assert_eq!(state.title, "Bug from Alice");
    assert_eq!(state.author.name, "Alice");
    assert_eq!(state.status, IssueStatus::Open);
}

#[test]
fn test_bob_comments_on_alice_issue_then_sync() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, syncs
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion");
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs, sees the issue, adds a comment, syncs
    sync::sync(&bob_repo, "origin").unwrap();
    let bob_ref = format!("refs/collab/issues/{}", id);
    add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this");
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs again — should see Bob's comment
    sync::sync(&alice_repo, "origin").unwrap();
    let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    assert_eq!(state.comments.len(), 1);
    assert_eq!(state.comments[0].author.name, "Bob");
    assert_eq!(state.comments[0].body, "I have thoughts on this");
}

#[test]
fn test_concurrent_comments_sync_convergence() {
    // Alice and Bob both comment on the same issue without syncing first.
    // After both sync, they should converge to the same state.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, syncs so Bob can get it
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Both comment independently (no sync between)
    add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take");
    let bob_ref = format!("refs/collab/issues/{}", id);
    add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take");

    // Alice syncs first — pushes her comment
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — fetches Alice's comment, reconciles fork, pushes merge
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs again to get the merge
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should now have the same state
    let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();

    assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments");
    assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments");

    // Both should have the same comment bodies (order may vary)
    let mut alice_bodies: Vec<&str> = alice_state
        .comments
        .iter()
        .map(|c| c.body.as_str())
        .collect();
    let mut bob_bodies: Vec<&str> = bob_state.comments.iter().map(|c| c.body.as_str()).collect();
    alice_bodies.sort();
    bob_bodies.sort();
    assert_eq!(alice_bodies, bob_bodies);
    assert!(alice_bodies.contains(&"Alice's take"));
    assert!(alice_bodies.contains(&"Bob's take"));
}

#[test]
fn test_both_create_different_issues() {
    // Alice and Bob each create their own issue without syncing.
    // After sync, both should see both issues.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    let (_, alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug");
    let (_, bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request");

    // Alice syncs first
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — gets Alice's issue, pushes his own
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs again — gets Bob's issue
    sync::sync(&alice_repo, "origin").unwrap();

    // Both repos should have both issues
    let alice_issues = state::list_issues(&alice_repo).unwrap();
    let bob_issues = state::list_issues(&bob_repo).unwrap();

    assert_eq!(alice_issues.len(), 2);
    assert_eq!(bob_issues.len(), 2);

    let alice_titles: Vec<&str> = alice_issues.iter().map(|i| i.title.as_str()).collect();
    assert!(alice_titles.contains(&"Alice's bug"));
    assert!(alice_titles.contains(&"Bob's feature request"));
}

#[test]
fn test_alice_closes_while_bob_comments() {
    // Alice closes an issue while Bob comments on it concurrently.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Close vs comment");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice closes
    close_issue(&alice_repo, &alice_ref, &alice());

    // Bob comments
    let bob_ref = format!("refs/collab/issues/{}", id);
    add_comment(&bob_repo, &bob_ref, &bob(), "But wait...");

    // Alice pushes first
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — reconciles the fork
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs to get the merge
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should see the comment AND the close
    let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();

    assert_eq!(alice_state.comments.len(), 1);
    assert_eq!(bob_state.comments.len(), 1);
    // Close happened, so status should be closed
    // (both close and comment are in DAG; topo replay applies both)
    assert_eq!(alice_state.status, bob_state.status);
}

#[test]
fn test_sync_idempotent() {
    // Syncing twice in a row should be a no-op the second time.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Idempotent test");
    add_comment(&alice_repo, &alice_ref, &alice(), "A comment");
    sync::sync(&alice_repo, "origin").unwrap();

    sync::sync(&bob_repo, "origin").unwrap();
    // Sync again immediately — should not fail or duplicate
    sync::sync(&bob_repo, "origin").unwrap();

    let bob_ref = format!("refs/collab/issues/{}", id);
    let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
    assert_eq!(state.comments.len(), 1, "no duplicate comments");
}

#[test]
fn test_three_user_convergence() {
    // Three users (Alice, Bob, Charlie) all working on the same issue.
    // Charlie uses Alice's repo path as a second remote.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, everyone syncs
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice and Bob both comment
    add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment");
    let bob_ref = format!("refs/collab/issues/{}", id);
    add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment");

    // Alice syncs, Bob syncs, Alice syncs again (full convergence)
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();

    assert_eq!(alice_state.comments.len(), 2);
    assert_eq!(bob_state.comments.len(), 2);
}

#[test]
fn test_patch_review_across_repos() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates a patch
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchCreate {
            title: "Add feature X".to_string(),
            body: "Please review".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
        },
    };
    let oid = dag::create_root_event(&alice_repo, &event).unwrap();
    let id = oid.to_string();
    let alice_ref = format!("refs/collab/patches/{}", id);
    alice_repo
        .reference(&alice_ref, oid, false, "patch create")
        .unwrap();

    // Alice syncs
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs, reviews the patch
    sync::sync(&bob_repo, "origin").unwrap();
    let bob_ref = format!("refs/collab/patches/{}", id);
    let review_event = Event {
        timestamp: now(),
        author: bob(),
        action: Action::PatchReview {
            verdict: ReviewVerdict::Approve,
            body: "LGTM!".to_string(),
        },
    };
    dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs and sees the review
    sync::sync(&alice_repo, "origin").unwrap();
    let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    assert_eq!(state.reviews.len(), 1);
    assert_eq!(state.reviews[0].verdict, ReviewVerdict::Approve);
    assert_eq!(state.reviews[0].author.name, "Bob");
}

#[test]
fn test_concurrent_review_and_revise() {
    // Bob reviews while Alice revises the patch concurrently.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates patch, syncs
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchCreate {
            title: "WIP feature".to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            head_commit: "v1".to_string(),
        },
    };
    let oid = dag::create_root_event(&alice_repo, &event).unwrap();
    let id = oid.to_string();
    let alice_ref = format!("refs/collab/patches/{}", id);
    alice_repo
        .reference(&alice_ref, oid, false, "patch")
        .unwrap();
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice revises (without syncing)
    let revise_event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchRevise {
            body: Some("Updated description".to_string()),
            head_commit: "v2".to_string(),
        },
    };
    dag::append_event(&alice_repo, &alice_ref, &revise_event).unwrap();

    // Bob reviews (without syncing)
    let bob_ref = format!("refs/collab/patches/{}", id);
    let review_event = Event {
        timestamp: now(),
        author: bob(),
        action: Action::PatchReview {
            verdict: ReviewVerdict::RequestChanges,
            body: "Needs work".to_string(),
        },
    };
    dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();

    // Both sync
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should see revise + review
    let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap();

    assert_eq!(alice_state.reviews.len(), 1);
    assert_eq!(bob_state.reviews.len(), 1);
    // The revise should have updated the head_commit
    assert_eq!(alice_state.head_commit, bob_state.head_commit);
}

#[test]
fn test_multiple_rounds_of_sync() {
    // Simulate a realistic back-and-forth conversation on an issue.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Round 1: Alice opens issue
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    let bob_ref = format!("refs/collab/issues/{}", id);

    // Round 2: Bob comments
    add_comment(&bob_repo, &bob_ref, &bob(), "First response");
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    // Round 3: Alice replies
    add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Round 4: Bob closes
    close_issue(&bob_repo, &bob_ref, &bob());
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    assert_eq!(state.comments.len(), 2);
    assert_eq!(state.status, IssueStatus::Closed);
    assert_eq!(state.comments[0].body, "First response");
    assert_eq!(state.comments[1].body, "Thanks for the input");
}