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"); }