From ced7e72044c0107e9e2601b4af81ae7f1205fda8 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Mon, 30 Mar 2026 20:41:45 -0700 Subject: [PATCH] test: add promisor integration tests and fixtures --- Cargo.lock | 599 ++++- Cargo.toml | 2 + tests/e2e/harness.rs | 423 ++++ tests/e2e/list.rs | 102 + tests/e2e/main.rs | 17 + tests/e2e/materialize.rs | 917 +++++++ tests/e2e/promisor.rs | 115 + tests/e2e/push_pull.rs | 247 ++ tests/e2e/remote.rs | 109 + tests/e2e/serialize.rs | 281 +++ tests/e2e/set_get.rs | 478 ++++ tests/fixtures/bare-with-history-retained.sh | 33 + tests/fixtures/bare-with-history.sh | 31 + tests/fixtures/bare-with-meta.sh | 23 + tests/fixtures/basic-repo.sh | 7 + tests/integration.rs | 2355 ------------------ 16 files changed, 3253 insertions(+), 2486 deletions(-) create mode 100644 tests/e2e/harness.rs create mode 100644 tests/e2e/list.rs create mode 100644 tests/e2e/main.rs create mode 100644 tests/e2e/materialize.rs create mode 100644 tests/e2e/promisor.rs create mode 100644 tests/e2e/push_pull.rs create mode 100644 tests/e2e/remote.rs create mode 100644 tests/e2e/serialize.rs create mode 100644 tests/e2e/set_get.rs create mode 100755 tests/fixtures/bare-with-history-retained.sh create mode 100755 tests/fixtures/bare-with-history.sh create mode 100755 tests/fixtures/bare-with-meta.sh create mode 100755 tests/fixtures/basic-repo.sh delete mode 100644 tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index a147bdf..9b298f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -389,7 +404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -473,6 +488,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -538,50 +559,50 @@ version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" dependencies = [ - "gix-actor", + "gix-actor 0.40.0", "gix-archive", - "gix-attributes", + "gix-attributes 0.31.0", "gix-blame", "gix-command", - "gix-commitgraph", + "gix-commitgraph 0.35.0", "gix-config", - "gix-date", + "gix-date 0.15.1", "gix-diff", "gix-dir", - "gix-discover", - "gix-error", + "gix-discover 0.49.0", + "gix-error 0.2.1", "gix-features", "gix-filter", "gix-fs", "gix-glob", - "gix-hash", - "gix-hashtable", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", "gix-ignore", - "gix-index", + "gix-index 0.49.0", "gix-lock", "gix-merge", "gix-negotiate", - "gix-object", + "gix-object 0.58.0", "gix-odb", "gix-pack", "gix-path", "gix-pathspec", "gix-protocol", - "gix-ref", + "gix-ref 0.61.0", "gix-refspec", "gix-revision", - "gix-revwalk", + "gix-revwalk 0.29.0", "gix-sec", "gix-shallow", "gix-status", "gix-submodule", "gix-tempfile", "gix-trace", - "gix-traverse", + "gix-traverse 0.55.0", "gix-url", "gix-utils", "gix-validate", - "gix-worktree", + "gix-worktree 0.50.0", "gix-worktree-state", "gix-worktree-stream", "nonempty", @@ -589,6 +610,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-actor" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50ce5433eaa46187349e59089eea71b0397caa71991b2fa3e124120426d7d15" +dependencies = [ + "bstr", + "gix-date 0.13.0", + "gix-utils", + "itoa", + "thiserror 2.0.18", + "winnow", +] + [[package]] name = "gix-actor" version = "0.40.0" @@ -596,8 +631,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" dependencies = [ "bstr", - "gix-date", - "gix-error", + "gix-date 0.15.1", + "gix-error 0.2.1", "winnow", ] @@ -608,12 +643,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" dependencies = [ "bstr", - "gix-date", - "gix-error", - "gix-object", + "gix-date 0.15.1", + "gix-error 0.2.1", + "gix-object 0.58.0", "gix-worktree-stream", ] +[[package]] +name = "gix-attributes" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e72da5a1c35c9a129be0c60ab9968779981ca50835dd98650ecd8b0ea4d721e" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote 0.6.2", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + [[package]] name = "gix-attributes" version = "0.31.0" @@ -623,7 +675,7 @@ dependencies = [ "bstr", "gix-glob", "gix-path", - "gix-quote", + "gix-quote 0.7.0", "gix-trace", "kstring", "smallvec", @@ -631,13 +683,22 @@ dependencies = [ "unicode-bom", ] +[[package]] +name = "gix-bitmap" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "gix-bitmap" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" dependencies = [ - "gix-error", + "gix-error 0.2.1", ] [[package]] @@ -646,27 +707,36 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" dependencies = [ - "gix-commitgraph", - "gix-date", + "gix-commitgraph 0.35.0", + "gix-date 0.15.1", "gix-diff", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", + "gix-error 0.2.1", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", "gix-trace", - "gix-traverse", - "gix-worktree", + "gix-traverse 0.55.0", + "gix-worktree 0.50.0", "smallvec", "thiserror 2.0.18", ] +[[package]] +name = "gix-chunk" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e516efaac951ed21115b11d5514b120c26ccb493d0c0b9ea6cc10edf4fdf44" +dependencies = [ + "gix-error 0.0.0", +] + [[package]] name = "gix-chunk" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" dependencies = [ - "gix-error", + "gix-error 0.2.1", ] [[package]] @@ -677,11 +747,24 @@ checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" dependencies = [ "bstr", "gix-path", - "gix-quote", + "gix-quote 0.7.0", "gix-trace", "shell-words", ] +[[package]] +name = "gix-commitgraph" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0dda2e4d5a61d4a16a780f61f2b7e9406ad1f8da97c35c09ef501f3fdf74de0" +dependencies = [ + "bstr", + "gix-chunk 0.5.0", + "gix-error 0.0.0", + "gix-hash 0.22.1", + "memmap2", +] + [[package]] name = "gix-commitgraph" version = "0.35.0" @@ -689,9 +772,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3196655fd1443f3c58a48c114aa480be3e4e87b393d7292daaa0d543862eb445" dependencies = [ "bstr", - "gix-chunk", - "gix-error", - "gix-hash", + "gix-chunk 0.7.0", + "gix-error 0.2.1", + "gix-hash 0.23.0", "memmap2", "nonempty", ] @@ -707,7 +790,7 @@ dependencies = [ "gix-features", "gix-glob", "gix-path", - "gix-ref", + "gix-ref 0.61.0", "gix-sec", "memchr", "smallvec", @@ -729,6 +812,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-date" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12553b32d1da25671f31c0b084bf1e5cb6d5ef529254d04ec33cdc890bd7f687" +dependencies = [ + "bstr", + "gix-error 0.0.0", + "itoa", + "jiff", + "smallvec", +] + [[package]] name = "gix-date" version = "0.15.1" @@ -736,7 +832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39acf819aa9fee65e4838a2eec5cb2506e47ebb89e02a5ab9918196e491571ea" dependencies = [ "bstr", - "gix-error", + "gix-error 0.2.1", "itoa", "jiff", "smallvec", @@ -752,13 +848,13 @@ dependencies = [ "gix-command", "gix-filter", "gix-fs", - "gix-hash", - "gix-object", + "gix-hash 0.23.0", + "gix-object 0.58.0", "gix-path", "gix-tempfile", "gix-trace", - "gix-traverse", - "gix-worktree", + "gix-traverse 0.55.0", + "gix-worktree 0.50.0", "imara-diff 0.1.8", "imara-diff 0.2.0", "thiserror 2.0.18", @@ -771,16 +867,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" dependencies = [ "bstr", - "gix-discover", + "gix-discover 0.49.0", "gix-fs", "gix-ignore", - "gix-index", - "gix-object", + "gix-index 0.49.0", + "gix-object 0.58.0", "gix-path", "gix-pathspec", "gix-trace", "gix-utils", - "gix-worktree", + "gix-worktree 0.50.0", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950b027b861c6863ddf1b075672ec1ef2006b95c4d12284fc1ec4cdb1ab6639e" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref 0.58.0", + "gix-sec", "thiserror 2.0.18", ] @@ -794,11 +905,20 @@ dependencies = [ "dunce", "gix-fs", "gix-path", - "gix-ref", + "gix-ref 0.61.0", "gix-sec", "thiserror 2.0.18", ] +[[package]] +name = "gix-error" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dffc9ca4dfa4f519a3d2cf1c038919160544923577ac60f45bcb602a24d82c6" +dependencies = [ + "bstr", +] + [[package]] name = "gix-error" version = "0.2.1" @@ -835,13 +955,13 @@ checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" dependencies = [ "bstr", "encoding_rs", - "gix-attributes", + "gix-attributes 0.31.0", "gix-command", - "gix-hash", - "gix-object", + "gix-hash 0.23.0", + "gix-object 0.58.0", "gix-packetline", "gix-path", - "gix-quote", + "gix-quote 0.7.0", "gix-trace", "gix-utils", "smallvec", @@ -874,6 +994,18 @@ dependencies = [ "gix-path", ] +[[package]] +name = "gix-hash" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ced05d2d7b13bff08b2f7eb4e47cfeaf00b974c2ddce08377c4fe1f706b3eb" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", +] + [[package]] name = "gix-hash" version = "0.23.0" @@ -886,13 +1018,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-hashtable" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f1eecdd006390cbed81f105417dbf82a6fe40842022006550f2e32484101da" +dependencies = [ + "gix-hash 0.22.1", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "gix-hashtable" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" dependencies = [ - "gix-hash", + "gix-hash 0.23.0", "hashbrown 0.16.1", "parking_lot", ] @@ -910,6 +1053,34 @@ dependencies = [ "unicode-bom", ] +[[package]] +name = "gix-index" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31c6b3664efe5916c539c50e610f9958f2993faf8e29fa5a40fb80b6ac8486a" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap 0.2.16", + "gix-features", + "gix-fs", + "gix-hash 0.22.1", + "gix-lock", + "gix-object 0.55.0", + "gix-traverse 0.52.0", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "gix-index" version = "0.49.0" @@ -920,13 +1091,13 @@ dependencies = [ "bstr", "filetime", "fnv", - "gix-bitmap", + "gix-bitmap 0.3.0", "gix-features", "gix-fs", - "gix-hash", + "gix-hash 0.23.0", "gix-lock", - "gix-object", - "gix-traverse", + "gix-object 0.58.0", + "gix-traverse 0.55.0", "gix-utils", "gix-validate", "hashbrown 0.16.1", @@ -960,16 +1131,16 @@ dependencies = [ "gix-diff", "gix-filter", "gix-fs", - "gix-hash", - "gix-index", - "gix-object", + "gix-hash 0.23.0", + "gix-index 0.49.0", + "gix-object 0.58.0", "gix-path", - "gix-quote", + "gix-quote 0.7.0", "gix-revision", - "gix-revwalk", + "gix-revwalk 0.29.0", "gix-tempfile", "gix-trace", - "gix-worktree", + "gix-worktree 0.50.0", "imara-diff 0.1.8", "nonempty", "thiserror 2.0.18", @@ -982,11 +1153,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" dependencies = [ "bitflags 2.11.0", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", + "gix-commitgraph 0.35.0", + "gix-date 0.15.1", + "gix-hash 0.23.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", +] + +[[package]] +name = "gix-object" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f705c977d90ace597049252ae1d7fec907edc0fa7616cc91bf5508d0f4006" +dependencies = [ + "bstr", + "gix-actor 0.38.0", + "gix-date 0.13.0", + "gix-features", + "gix-hash 0.22.1", + "gix-hashtable 0.12.0", + "gix-path", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", + "winnow", ] [[package]] @@ -996,11 +1188,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" dependencies = [ "bstr", - "gix-actor", - "gix-date", + "gix-actor 0.40.0", + "gix-date 0.15.1", "gix-features", - "gix-hash", - "gix-hashtable", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", "gix-path", "gix-utils", "gix-validate", @@ -1019,12 +1211,12 @@ dependencies = [ "arc-swap", "gix-features", "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", "gix-pack", "gix-path", - "gix-quote", + "gix-quote 0.7.0", "parking_lot", "tempfile", "thiserror 2.0.18", @@ -1037,12 +1229,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3484119cd19859d7d7639413c27e192478fa354d3f4ff5f7e3c041e8040f0f4" dependencies = [ "clru", - "gix-chunk", - "gix-error", + "gix-chunk 0.7.0", + "gix-error 0.2.1", "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", "gix-path", "memmap2", "smallvec", @@ -1081,7 +1273,7 @@ checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" dependencies = [ "bitflags 2.11.0", "bstr", - "gix-attributes", + "gix-attributes 0.31.0", "gix-config-value", "gix-glob", "gix-path", @@ -1095,10 +1287,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" dependencies = [ "bstr", - "gix-date", + "gix-date 0.15.1", "gix-features", - "gix-hash", - "gix-ref", + "gix-hash 0.23.0", + "gix-ref 0.61.0", "gix-shallow", "gix-transport", "gix-utils", @@ -1108,6 +1300,17 @@ dependencies = [ "winnow", ] +[[package]] +name = "gix-quote" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fc2ff2ec8cc0c92807f02eab1f00eb02619fc2810d13dc42679492fcc36757" +dependencies = [ + "bstr", + "gix-utils", + "thiserror 2.0.18", +] + [[package]] name = "gix-quote" version = "0.7.0" @@ -1115,22 +1318,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" dependencies = [ "bstr", - "gix-error", + "gix-error 0.2.1", "gix-utils", ] +[[package]] +name = "gix-ref" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf780dcd9ac99fd3fcfc8523479a0e2ffd55f5e0be63e5e3248fb7e46cff966" +dependencies = [ + "gix-actor 0.38.0", + "gix-features", + "gix-fs", + "gix-hash 0.22.1", + "gix-lock", + "gix-object 0.55.0", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", + "winnow", +] + [[package]] name = "gix-ref" version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" dependencies = [ - "gix-actor", + "gix-actor 0.40.0", "gix-features", "gix-fs", - "gix-hash", + "gix-hash 0.23.0", "gix-lock", - "gix-object", + "gix-object 0.58.0", "gix-path", "gix-tempfile", "gix-utils", @@ -1147,9 +1371,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" dependencies = [ "bstr", - "gix-error", + "gix-error 0.2.1", "gix-glob", - "gix-hash", + "gix-hash 0.23.0", "gix-revision", "gix-validate", "smallvec", @@ -1164,29 +1388,45 @@ checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" dependencies = [ "bitflags 2.11.0", "bstr", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", + "gix-commitgraph 0.35.0", + "gix-date 0.15.1", + "gix-error 0.2.1", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", "gix-trace", "nonempty", ] +[[package]] +name = "gix-revwalk" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a50b30aa0c6e6de43c723359c5809a96275a3aa92d323ef7f58b1cdd60f16" +dependencies = [ + "gix-commitgraph 0.32.0", + "gix-date 0.13.0", + "gix-error 0.0.0", + "gix-hash 0.22.1", + "gix-hashtable 0.12.0", + "gix-object 0.55.0", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "gix-revwalk" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", + "gix-commitgraph 0.35.0", + "gix-date 0.15.1", + "gix-error 0.2.1", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", "smallvec", "thiserror 2.0.18", ] @@ -1210,7 +1450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" dependencies = [ "bstr", - "gix-hash", + "gix-hash 0.23.0", "gix-lock", "nonempty", "thiserror 2.0.18", @@ -1229,12 +1469,12 @@ dependencies = [ "gix-features", "gix-filter", "gix-fs", - "gix-hash", - "gix-index", - "gix-object", + "gix-hash 0.23.0", + "gix-index 0.49.0", + "gix-object 0.58.0", "gix-path", "gix-pathspec", - "gix-worktree", + "gix-worktree 0.50.0", "portable-atomic", "thiserror 2.0.18", ] @@ -1264,7 +1504,32 @@ dependencies = [ "gix-fs", "libc", "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-testtools" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2a12c424a4be945e77b1381b4ffc1a01f6d0b8901f988991451ac69c4b46f1c" +dependencies = [ + "bstr", + "crc", + "fastrand", + "fs_extra", + "gix-discover 0.46.0", + "gix-fs", + "gix-lock", + "gix-tempfile", + "gix-worktree 0.47.0", + "io-close", + "is_ci", + "parking_lot", + "tar", "tempfile", + "winnow", ] [[package]] @@ -1283,12 +1548,29 @@ dependencies = [ "gix-command", "gix-features", "gix-packetline", - "gix-quote", + "gix-quote 0.7.0", "gix-sec", "gix-url", "thiserror 2.0.18", ] +[[package]] +name = "gix-traverse" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37f8b53b4c56b01c43a4491c4edfe2ce66c654eb86232205172ceb1650d21c55" +dependencies = [ + "bitflags 2.11.0", + "gix-commitgraph 0.32.0", + "gix-date 0.13.0", + "gix-hash 0.22.1", + "gix-hashtable 0.12.0", + "gix-object 0.55.0", + "gix-revwalk 0.26.0", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "gix-traverse" version = "0.55.0" @@ -1296,12 +1578,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" dependencies = [ "bitflags 2.11.0", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", + "gix-commitgraph 0.35.0", + "gix-date 0.15.1", + "gix-hash 0.23.0", + "gix-hashtable 0.13.0", + "gix-object 0.58.0", + "gix-revwalk 0.29.0", "smallvec", "thiserror 2.0.18", ] @@ -1338,6 +1620,24 @@ dependencies = [ "bstr", ] +[[package]] +name = "gix-worktree" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2ad658586ec0039b03e96c664f08b7cb7a2b7cca6947a9c856c9ed59b807b1" +dependencies = [ + "bstr", + "gix-attributes 0.30.1", + "gix-fs", + "gix-glob", + "gix-hash 0.22.1", + "gix-ignore", + "gix-index 0.46.0", + "gix-object 0.55.0", + "gix-path", + "gix-validate", +] + [[package]] name = "gix-worktree" version = "0.50.0" @@ -1345,13 +1645,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" dependencies = [ "bstr", - "gix-attributes", + "gix-attributes 0.31.0", "gix-fs", "gix-glob", - "gix-hash", + "gix-hash 0.23.0", "gix-ignore", - "gix-index", - "gix-object", + "gix-index 0.49.0", + "gix-object 0.58.0", "gix-path", "gix-validate", ] @@ -1366,10 +1666,10 @@ dependencies = [ "gix-features", "gix-filter", "gix-fs", - "gix-index", - "gix-object", + "gix-index 0.49.0", + "gix-object 0.58.0", "gix-path", - "gix-worktree", + "gix-worktree 0.50.0", "io-close", "thiserror 2.0.18", ] @@ -1380,15 +1680,15 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" dependencies = [ - "gix-attributes", - "gix-error", + "gix-attributes 0.31.0", + "gix-error 0.2.1", "gix-features", "gix-filter", "gix-fs", - "gix-hash", - "gix-object", + "gix-hash 0.23.0", + "gix-object 0.58.0", "gix-path", - "gix-traverse", + "gix-traverse 0.55.0", "parking_lot", ] @@ -1403,6 +1703,7 @@ dependencies = [ "dialoguer", "git2", "gix", + "gix-testtools", "notify", "predicates", "rusqlite", @@ -1676,6 +1977,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1700,7 +2007,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2206,7 +2513,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2323,6 +2630,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2369,6 +2696,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2376,10 +2713,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2683,7 +3020,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec0047b..7e483c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,7 @@ notify = "7" [dev-dependencies] assert_cmd = "2" +gix-testtools = "0.18" predicates = "3" +sha1 = "0.10" tempfile = "3" diff --git a/tests/e2e/harness.rs b/tests/e2e/harness.rs new file mode 100644 index 0000000..2f90bf6 --- /dev/null +++ b/tests/e2e/harness.rs @@ -0,0 +1,423 @@ +//! Test harness for gmeta end-to-end tests. +//! +//! Provides isolated, reproducible test environments inspired by GitButler's +//! `but-testsupport` crate. Key features: +//! +//! - **Shell script fixtures** via `gix-testtools` — readable, cacheable repo setup. +//! - **Environment isolation** — strips host git config, sets stable author/committer +//! dates, and disables gpgsign so tests are reproducible across machines. + +use assert_cmd::Command; +use sha1::{Digest, Sha1}; +use std::path::Path; +use tempfile::TempDir; + +// ── Environment isolation ──────────────────────────────────────────────────── + +#[cfg(not(windows))] +const NULL_DEVICE: &str = "/dev/null"; +#[cfg(windows)] +const NULL_DEVICE: &str = "NUL"; + +/// Environment variables to strip so host config doesn't leak into tests. +const ENV_VARS_TO_REMOVE: &[&str] = &[ + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_EDITOR", + "VISUAL", + "EDITOR", +]; + +/// Apply environment isolation to an `assert_cmd::Command`. +/// +/// Strips variables that could leak host state (GIT_DIR, GIT_EDITOR, etc.) +/// and sets stable, reproducible values for author, committer, config, and +/// signing. Ported from GitButler's `prepare_cmd_env` pattern. +fn isolate_cmd(cmd: &mut Command) { + for var in ENV_VARS_TO_REMOVE { + cmd.env_remove(var); + } + cmd.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", NULL_DEVICE) + .env("GIT_TERMINAL_PROMPT", "false") + .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_AUTHOR_NAME", "Test User") + .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test User") + .env("GIT_CONFIG_COUNT", "3") + .env("GIT_CONFIG_KEY_0", "commit.gpgsign") + .env("GIT_CONFIG_VALUE_0", "false") + .env("GIT_CONFIG_KEY_1", "tag.gpgsign") + .env("GIT_CONFIG_VALUE_1", "false") + .env("GIT_CONFIG_KEY_2", "init.defaultBranch") + .env("GIT_CONFIG_VALUE_2", "main"); +} + +// ── Command helpers ────────────────────────────────────────────────────────── + +/// Build an isolated `gmeta` [`Command`] pointed at `dir`. +/// +/// The command has full environment isolation applied so tests are reproducible. +pub fn gmeta(dir: &Path) -> Command { + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("gmeta"); + cmd.current_dir(dir); + isolate_cmd(&mut cmd); + cmd +} + +// ── Fixture helpers ────────────────────────────────────────────────────────── + +/// Get a writable copy of the `tests/fixtures/{name}.sh` fixture. +/// +/// Returns `(TempDir, initial_commit_sha)`. The `TempDir` owns the working +/// directory; dropping it cleans up. +pub fn writable_fixture(name: &str) -> (TempDir, String) { + let tmp = gix_testtools::scripted_fixture_writable(name) + .unwrap_or_else(|e| panic!("fixture '{name}' failed: {e}")); + let sha = head_sha(tmp.path()); + (tmp, sha) +} + +/// Get a writable copy of the `tests/fixtures/{name}.sh` fixture, passing +/// `args` to the script. +/// +/// Returns the `TempDir` that owns the fixture directory. The directory +/// may contain a bare repo (no HEAD to extract). +pub fn writable_fixture_with_args( + name: &str, + args: impl IntoIterator>, +) -> TempDir { + gix_testtools::scripted_fixture_writable_with_args( + name, + args, + gix_testtools::Creation::CopyFromReadOnly, + ) + .unwrap_or_else(|e| panic!("fixture '{name}' (with args) failed: {e}")) +} + +/// Extract the HEAD commit SHA from a git repository at `path`. +fn head_sha(path: &std::path::Path) -> String { + let repo = git2::Repository::open(path).expect("fixture should be a valid git repo"); + let oid = repo + .head() + .expect("fixture repo should have HEAD") + .peel_to_commit() + .expect("HEAD should point to a commit") + .id(); + oid.to_string() +} + +// ── Target helpers ─────────────────────────────────────────────────────────── + +/// Build a `commit:` target string. +pub fn commit_target(sha: &str) -> String { + format!("commit:{sha}") +} + +/// Compute the two-character fanout prefix for a value (first two hex chars of +/// its SHA-1 hash). Used to verify serialized tree paths. +pub fn target_fanout(value: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(value.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + hash[..2].to_string() +} + +// ── Repo setup helpers (for tests that need custom repo configurations) ────── + +/// Create a fresh git repository in a new temp directory with user config set. +/// +/// Returns `(TempDir, initial_commit_sha)`. Use this when a test needs a repo +/// that doesn't match any fixture (e.g. custom user email, specific file +/// content, or multi-repo scenarios). +pub fn setup_repo() -> (TempDir, String) { + let dir = TempDir::new().expect("should be able to create temp dir"); + let repo = git2::Repository::init(dir.path()).expect("should be able to init repo"); + + let mut config = repo.config().expect("should be able to get config"); + config + .set_str("user.email", "test@example.com") + .expect("should be able to set email"); + config + .set_str("user.name", "Test User") + .expect("should be able to set name"); + + let sig = + git2::Signature::now("Test User", "test@example.com").expect("should create signature"); + let tree_oid = repo + .treebuilder(None) + .expect("should create treebuilder") + .write() + .expect("should write tree"); + let tree = repo.find_tree(tree_oid).expect("should find tree"); + let commit_oid = repo + .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]) + .expect("should create commit"); + + (dir, commit_oid.to_string()) +} + +/// Create a bare repo with a `refs/{ns}/main` ref containing gmeta tree +/// structure: `project/testing/__value = "hello"`. +/// +/// Used as a remote for push/pull tests. +pub fn setup_bare_with_meta(ns: &str) -> TempDir { + let bare_dir = TempDir::new().expect("should be able to create temp dir"); + let bare = + git2::Repository::init_bare(bare_dir.path()).expect("should be able to init bare repo"); + + let sig = + git2::Signature::now("Test User", "test@example.com").expect("should create signature"); + let mut tb = bare + .treebuilder(None) + .expect("should create root treebuilder"); + + // Build tree: project/testing/__value (blob: "hello") + let blob_oid = bare.blob(b"\"hello\"").expect("should create blob"); + let mut sub_tb = bare + .treebuilder(None) + .expect("should create value treebuilder"); + sub_tb + .insert("__value", blob_oid, 0o100644) + .expect("should insert __value"); + let sub_tree_oid = sub_tb.write().expect("should write value tree"); + + let mut project_tb = bare + .treebuilder(None) + .expect("should create project treebuilder"); + project_tb + .insert("testing", sub_tree_oid, 0o040000) + .expect("should insert testing subtree"); + let project_tree_oid = project_tb.write().expect("should write project tree"); + + tb.insert("project", project_tree_oid, 0o040000) + .expect("should insert project tree"); + let tree_oid = tb.write().expect("should write root tree"); + let tree = bare.find_tree(tree_oid).expect("should find root tree"); + + let ref_name = format!("refs/{ns}/main"); + bare.commit(Some(&ref_name), &sig, &sig, "initial meta", &tree, &[]) + .expect("should create meta commit"); + + bare_dir +} + +/// Build a bare repo with multiple gmeta serialize commits for promisor tests. +/// +/// The repo has 2 commits on `refs/meta/main`: +/// - Commit 1 (older): `project/old_key/__value = "old_value"` +/// - Commit 2 (tip): `project/testing/__value = "hello"` (old_key removed) +pub fn setup_bare_with_history() -> TempDir { + let bare_dir = TempDir::new().expect("should be able to create temp dir"); + let bare = + git2::Repository::init_bare(bare_dir.path()).expect("should be able to init bare repo"); + let sig = + git2::Signature::now("Test User", "test@example.com").expect("should create signature"); + + // --- Commit 1: project/old_key/__value = "old_value" --- + let blob1 = bare.blob(b"\"old_value\"").expect("should create blob"); + let mut val_tb = bare + .treebuilder(None) + .expect("should create value treebuilder"); + val_tb + .insert("__value", blob1, 0o100644) + .expect("should insert __value"); + let val_tree = val_tb.write().expect("should write value tree"); + + let mut proj_tb = bare + .treebuilder(None) + .expect("should create project treebuilder"); + proj_tb + .insert("old_key", val_tree, 0o040000) + .expect("should insert old_key"); + let proj_tree = proj_tb.write().expect("should write project tree"); + + let mut root_tb = bare + .treebuilder(None) + .expect("should create root treebuilder"); + root_tb + .insert("project", proj_tree, 0o040000) + .expect("should insert project tree"); + let root_tree_oid = root_tb.write().expect("should write root tree"); + let root_tree = bare.find_tree(root_tree_oid).expect("should find tree"); + + let commit1_msg = "gmeta: serialize (1 changes)\n\nA\tproject\told_key"; + let commit1 = bare + .commit(None, &sig, &sig, commit1_msg, &root_tree, &[]) + .expect("should create commit 1"); + let commit1_obj = bare.find_commit(commit1).expect("should find commit 1"); + + // --- Commit 2 (tip): project/testing/__value = "hello" (old_key removed) --- + let blob2 = bare.blob(b"\"hello\"").expect("should create blob"); + let mut val_tb2 = bare + .treebuilder(None) + .expect("should create value treebuilder"); + val_tb2 + .insert("__value", blob2, 0o100644) + .expect("should insert __value"); + let val_tree2 = val_tb2.write().expect("should write value tree"); + + let mut proj_tb2 = bare + .treebuilder(None) + .expect("should create project treebuilder"); + proj_tb2 + .insert("testing", val_tree2, 0o040000) + .expect("should insert testing"); + let proj_tree2 = proj_tb2.write().expect("should write project tree"); + + let mut root_tb2 = bare + .treebuilder(None) + .expect("should create root treebuilder"); + root_tb2 + .insert("project", proj_tree2, 0o040000) + .expect("should insert project tree"); + let root_tree_oid2 = root_tb2.write().expect("should write root tree"); + let root_tree2 = bare.find_tree(root_tree_oid2).expect("should find tree"); + + let commit2_msg = "gmeta: serialize (1 changes)\n\nA\tproject\ttesting"; + bare.commit( + Some("refs/meta/main"), + &sig, + &sig, + commit2_msg, + &root_tree2, + &[&commit1_obj], + ) + .expect("should create commit 2"); + + bare_dir +} + +/// Build a bare repo where a key exists in both history and tip tree. +/// +/// Like [`setup_bare_with_history`] but the tip commit retains `old_key` in +/// its tree alongside `testing`. The tip commit message only mentions `testing`. +pub fn setup_bare_with_history_retained() -> TempDir { + let bare_dir = TempDir::new().expect("should be able to create temp dir"); + let bare = + git2::Repository::init_bare(bare_dir.path()).expect("should be able to init bare repo"); + let sig = + git2::Signature::now("Test User", "test@example.com").expect("should create signature"); + + // --- Commit 1: project/old_key/__value = "old_value" --- + let blob1 = bare.blob(b"\"old_value\"").expect("should create blob"); + let mut val_tb = bare + .treebuilder(None) + .expect("should create value treebuilder"); + val_tb + .insert("__value", blob1, 0o100644) + .expect("should insert __value"); + let val_tree = val_tb.write().expect("should write value tree"); + + let mut proj_tb = bare + .treebuilder(None) + .expect("should create project treebuilder"); + proj_tb + .insert("old_key", val_tree, 0o040000) + .expect("should insert old_key"); + let proj_tree = proj_tb.write().expect("should write project tree"); + + let mut root_tb = bare + .treebuilder(None) + .expect("should create root treebuilder"); + root_tb + .insert("project", proj_tree, 0o040000) + .expect("should insert project tree"); + let root_tree_oid = root_tb.write().expect("should write root tree"); + let root_tree = bare.find_tree(root_tree_oid).expect("should find tree"); + + let commit1_msg = "gmeta: serialize (1 changes)\n\nA\tproject\told_key"; + let commit1 = bare + .commit(None, &sig, &sig, commit1_msg, &root_tree, &[]) + .expect("should create commit 1"); + let commit1_obj = bare.find_commit(commit1).expect("should find commit 1"); + + // --- Commit 2 (tip): both old_key and testing --- + let blob2 = bare.blob(b"\"hello\"").expect("should create blob"); + let mut val_tb2 = bare + .treebuilder(None) + .expect("should create value treebuilder"); + val_tb2 + .insert("__value", blob2, 0o100644) + .expect("should insert __value"); + let val_tree2 = val_tb2.write().expect("should write value tree"); + + let mut proj_tb2 = bare + .treebuilder(None) + .expect("should create project treebuilder"); + proj_tb2 + .insert("old_key", val_tree, 0o040000) + .expect("should insert old_key"); + proj_tb2 + .insert("testing", val_tree2, 0o040000) + .expect("should insert testing"); + let proj_tree2 = proj_tb2.write().expect("should write project tree"); + + let mut root_tb2 = bare + .treebuilder(None) + .expect("should create root treebuilder"); + root_tb2 + .insert("project", proj_tree2, 0o040000) + .expect("should insert project tree"); + let root_tree_oid2 = root_tb2.write().expect("should write root tree"); + let root_tree2 = bare.find_tree(root_tree_oid2).expect("should find tree"); + + let commit2_msg = "gmeta: serialize (1 changes)\n\nA\tproject\ttesting"; + bare.commit( + Some("refs/meta/main"), + &sig, + &sig, + commit2_msg, + &root_tree2, + &[&commit1_obj], + ) + .expect("should create commit 2"); + + bare_dir +} + +// ── Object transfer helpers (simulate push/pull without network) ───────────── + +/// Copy all git objects from `src` repo into a bare repo at `bare_dir`. +/// +/// Simulates a push by copying loose objects and pack files. +pub fn copy_meta_objects(src: &git2::Repository, bare_dir: &TempDir) { + let src_objects = src.path().join("objects"); + let dst_objects = bare_dir.path().join("objects"); + copy_dir_contents(&src_objects, &dst_objects); +} + +/// Copy all git objects from a bare repo at `bare_dir` into `dst` repo. +/// +/// Simulates a fetch by copying loose objects and pack files. +pub fn copy_meta_objects_from(bare_dir: &TempDir, dst: &git2::Repository) { + let src_objects = bare_dir.path().join("objects"); + let dst_objects = dst.path().join("objects"); + copy_dir_contents(&src_objects, &dst_objects); +} + +/// Recursively copy directory contents (for loose objects + pack files). +fn copy_dir_contents(src: &std::path::Path, dst: &std::path::Path) { + if !src.exists() { + return; + } + for entry in std::fs::read_dir(src).expect("should be able to read dir") { + let entry = entry.expect("should be a valid entry"); + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + std::fs::create_dir_all(&dst_path).ok(); + copy_dir_contents(&src_path, &dst_path); + } else { + std::fs::copy(&src_path, &dst_path).ok(); + } + } +} diff --git a/tests/e2e/list.rs b/tests/e2e/list.rs new file mode 100644 index 0000000..067a7ec --- /dev/null +++ b/tests/e2e/list.rs @@ -0,0 +1,102 @@ +use predicates::prelude::*; + +use crate::harness::{self, commit_target, setup_repo}; + +#[test] +fn list_push() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["list:push", &target, "tags", "first"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["list:push", &target, "tags", "second"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "tags"]) + .assert() + .success() + .stdout(predicate::str::contains("first")) + .stdout(predicate::str::contains("second")); +} + +#[test] +fn list_push_converts_string_to_list() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "note", "original"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["list:push", &target, "note", "appended"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "note"]) + .assert() + .success() + .stdout(predicate::str::contains("original")) + .stdout(predicate::str::contains("appended")); +} + +#[test] +fn list_pop() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["list:push", &target, "tags", "a"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["list:push", &target, "tags", "b"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["list:pop", &target, "tags", "b"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "tags"]) + .assert() + .success() + .stdout(predicate::str::contains("a")) + .stdout(predicate::str::contains("b").not()); +} + +#[test] +fn set_list_type() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + &target, + "items", + r#"["hello","world"]"#, + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "items"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")) + .stdout(predicate::str::contains("world")); +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs new file mode 100644 index 0000000..5025705 --- /dev/null +++ b/tests/e2e/main.rs @@ -0,0 +1,17 @@ +//! End-to-end tests for gmeta. +//! +//! These tests exercise the `gmeta` CLI binary against real git repositories, +//! verifying the full round-trip of metadata operations. The test harness +//! provides environment isolation and fixture-based repo setup inspired by +//! GitButler's `but-testsupport` crate. + +#[allow(dead_code)] +mod harness; + +mod list; +mod materialize; +mod promisor; +mod push_pull; +mod remote; +mod serialize; +mod set_get; diff --git a/tests/e2e/materialize.rs b/tests/e2e/materialize.rs new file mode 100644 index 0000000..e7031ae --- /dev/null +++ b/tests/e2e/materialize.rs @@ -0,0 +1,917 @@ +use predicates::prelude::*; +use tempfile::TempDir; + +use crate::harness::{ + self, commit_target, copy_meta_objects, copy_meta_objects_from, setup_repo, target_fanout, +}; + +#[test] +fn fast_forward_applies_remote_removal() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "v1"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let first_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["rm", &target, "agent:model"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let second_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + + repo.reference("refs/meta/origin", second_oid, true, "test remote") + .unwrap(); + repo.reference("refs/meta/local/main", first_oid, true, "rollback local") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "stale"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::is_empty()); +} + +#[test] +fn fast_forward_applies_remote_list_entry_removal() { + let (dir, _sha) = setup_repo(); + let target = "branch:sc-branch-1-deadbeef"; + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + target, + "agent:chat", + r#"["a","b","c"]"#, + ]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let first_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["list:pop", target, "agent:chat", "b"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let second_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + + repo.reference("refs/meta/origin", second_oid, true, "test remote") + .unwrap(); + repo.reference("refs/meta/local/main", first_oid, true, "rollback local") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + target, + "agent:chat", + r#"["a","b","c"]"#, + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", target, "agent:chat"]) + .assert() + .success() + .stdout(predicate::str::contains("a")) + .stdout(predicate::str::contains("c")) + .stdout(predicate::str::contains("b").not()); +} + +/// The core durability guarantee: metadata survives a complete database loss +/// as long as it has been serialized to git refs. +/// +/// 1. Set metadata (string, list, set) across multiple target types +/// 2. Serialize to a git ref +/// 3. Delete the SQLite database entirely +/// 4. Materialize from the serialized ref +/// 5. Verify all data is fully restored +#[test] +fn serialize_wipe_db_materialize_restores_all_data() { + let (dir, sha) = setup_repo(); + let commit = commit_target(&sha); + + // Set a string value on a commit target. + harness::gmeta(dir.path()) + .args(["set", &commit, "agent:model", "claude-4.6"]) + .assert() + .success(); + + // Set a string value on a project target. + harness::gmeta(dir.path()) + .args(["set", "project", "name", "my-project"]) + .assert() + .success(); + + // Set a list value on a branch target. + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + "branch:sc-feature-abc123", + "agent:chat", + r#"["hello","world"]"#, + ]) + .assert() + .success(); + + // Set a set value on a branch target. + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "set", + "branch:sc-feature-abc123", + "reviewer", + r#"["alice@example.com","bob@example.com"]"#, + ]) + .assert() + .success(); + + // Serialize everything to refs/meta/local/main. + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + // Copy the local ref to a "remote" ref and delete the local ref so + // materialize sees the remote as ahead and performs a fast-forward. + let repo = git2::Repository::open(dir.path()).unwrap(); + let local_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + repo.reference("refs/meta/origin", local_oid, true, "simulate remote") + .unwrap(); + // Delete the local ref — materialize needs to see the remote as ahead. + repo.find_reference("refs/meta/local/main") + .unwrap() + .delete() + .unwrap(); + drop(repo); + + // Nuke the SQLite database — simulates total data loss. + let db_path = dir.path().join(".git").join("gmeta.sqlite"); + assert!(db_path.exists(), "database should exist before deletion"); + std::fs::remove_file(&db_path).expect("should delete database"); + // Also remove the WAL/SHM files if present. + let _ = std::fs::remove_file(dir.path().join(".git").join("gmeta.sqlite-wal")); + let _ = std::fs::remove_file(dir.path().join(".git").join("gmeta.sqlite-shm")); + + // Materialize rebuilds the database from the serialized git tree. + harness::gmeta(dir.path()) + .args(["materialize"]) + .assert() + .success(); + + // Verify the string value on the commit target is restored. + harness::gmeta(dir.path()) + .args(["get", &commit, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-4.6")); + + // Verify the project value is restored. + harness::gmeta(dir.path()) + .args(["get", "project", "name"]) + .assert() + .success() + .stdout(predicate::str::contains("my-project")); + + // Verify list entries are restored. + harness::gmeta(dir.path()) + .args(["get", "branch:sc-feature-abc123", "agent:chat"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")) + .stdout(predicate::str::contains("world")); + + // Verify set members are restored. + harness::gmeta(dir.path()) + .args(["get", "branch:sc-feature-abc123", "reviewer"]) + .assert() + .success() + .stdout(predicate::str::contains("alice@example.com")) + .stdout(predicate::str::contains("bob@example.com")); + + // Verify the data round-trips through a second serialize — the re-serialized + // tree should be structurally identical (same commit SHA or same tree content). + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let tree = reference.peel_to_commit().unwrap().tree().unwrap(); + + // Spot-check: the commit target path should exist in the re-serialized tree. + let first2 = &sha[..2]; + let expected_path = format!("commit/{}/{}/agent/model/__value", first2, sha); + let mut found = false; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full = format!("{}{}", root, entry.name().unwrap_or("")); + if full == expected_path { + let blob = repo.find_blob(entry.id()).unwrap(); + assert_eq!(std::str::from_utf8(blob.content()).unwrap(), "claude-4.6"); + found = true; + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + assert!(found, "commit metadata should survive the full round-trip"); + + // Spot-check: the list entries should exist in the re-serialized tree. + let fanout = target_fanout("sc-feature-abc123"); + let list_prefix = format!("branch/{}/sc-feature-abc123/agent/chat/__list/", fanout); + let mut list_count = 0; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full = format!("{}{}", root, entry.name().unwrap_or("")); + if full.starts_with(&list_prefix) && entry.kind() == Some(git2::ObjectType::Blob) { + list_count += 1; + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + assert_eq!( + list_count, 2, + "list entries should survive the full round-trip" + ); +} + +/// Simulate the full round-trip: +/// +/// 1. User A sets metadata, serializes, "pushes" (we copy the ref) +/// 2. User B pulls, materializes (no new data), "pushes" the materialize commit +/// 3. User A pulls that back, overwrites a value locally, serializes +/// 4. User A materializes — the local change should NOT be overwritten +#[test] +fn preserves_local_changes_over_stale_remote() { + let bare_dir = TempDir::new().unwrap(); + let repo_a_dir = TempDir::new().unwrap(); + let repo_b_dir = TempDir::new().unwrap(); + + git2::Repository::init_bare(bare_dir.path()).unwrap(); + + let repo_a = git2::Repository::init(repo_a_dir.path()).unwrap(); + { + let mut config = repo_a.config().unwrap(); + config.set_str("user.email", "alice@example.com").unwrap(); + config.set_str("user.name", "Alice").unwrap(); + } + repo_a + .remote("origin", bare_dir.path().to_str().unwrap()) + .unwrap(); + let sig_a = git2::Signature::now("Alice", "alice@example.com").unwrap(); + let tree_oid = repo_a.treebuilder(None).unwrap().write().unwrap(); + let tree = repo_a.find_tree(tree_oid).unwrap(); + let init_oid = repo_a + .commit(Some("HEAD"), &sig_a, &sig_a, "initial", &tree, &[]) + .unwrap(); + + repo_a + .reference("refs/remotes/origin/main", init_oid, true, "init") + .unwrap(); + + let repo_b = git2::Repository::init(repo_b_dir.path()).unwrap(); + { + let mut config = repo_b.config().unwrap(); + config.set_str("user.email", "bob@example.com").unwrap(); + config.set_str("user.name", "Bob").unwrap(); + } + repo_b + .remote("origin", bare_dir.path().to_str().unwrap()) + .unwrap(); + let sig_b = git2::Signature::now("Bob", "bob@example.com").unwrap(); + let tree_oid_b = repo_b.treebuilder(None).unwrap().write().unwrap(); + let tree_b = repo_b.find_tree(tree_oid_b).unwrap(); + repo_b + .commit(Some("HEAD"), &sig_b, &sig_b, "initial", &tree_b, &[]) + .unwrap(); + + // === Step 1: User A sets metadata and serializes === + harness::gmeta(repo_a_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + "alice@example.com", + ]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "license", + "apache", + ]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let a_local_ref = repo_a.find_reference("refs/meta/local/main").unwrap(); + let a_local_oid = a_local_ref.peel_to_commit().unwrap().id(); + copy_meta_objects(&repo_a, &bare_dir); + let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); + bare_repo + .reference("refs/meta/local/main", a_local_oid, true, "push from A") + .unwrap(); + + // === Step 2: User B pulls and materializes (no new data) === + copy_meta_objects_from(&bare_dir, &repo_b); + repo_b + .reference("refs/meta/origin", a_local_oid, true, "fetch from bare") + .unwrap(); + + harness::gmeta(repo_b_dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(repo_b_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let b_local_ref = repo_b.find_reference("refs/meta/local/main").unwrap(); + let b_local_oid = b_local_ref.peel_to_commit().unwrap().id(); + copy_meta_objects(&repo_b, &bare_dir); + let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); + bare_repo + .reference("refs/meta/local/main", b_local_oid, true, "push from B") + .unwrap(); + + // === Step 3: User A pulls B's ref, overwrites a value locally, serializes === + copy_meta_objects_from(&bare_dir, &repo_a); + let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); + let bare_local = bare_repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + repo_a + .reference("refs/meta/origin", bare_local, true, "fetch from bare") + .unwrap(); + + harness::gmeta(repo_a_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + "tom@example.com", + ]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + // === Step 4: User A materializes — local change must survive === + harness::gmeta(repo_a_dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args(["get", "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp"]) + .assert() + .success() + .stdout(predicate::str::contains("testing:user")) + .stdout(predicate::str::contains("tom@example.com")) + .stdout(predicate::str::contains("alice@example.com").not()); + + harness::gmeta(repo_a_dir.path()) + .args([ + "get", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "license", + ]) + .assert() + .success() + .stdout(predicate::str::contains("apache")); +} + +/// Both User A and User B modify the same key. The one with the later +/// commit timestamp should win in the three-way merge. +#[test] +fn both_sides_modified_later_timestamp_wins() { + let bare_dir = TempDir::new().unwrap(); + let repo_a_dir = TempDir::new().unwrap(); + let repo_b_dir = TempDir::new().unwrap(); + + git2::Repository::init_bare(bare_dir.path()).unwrap(); + + let repo_a = git2::Repository::init(repo_a_dir.path()).unwrap(); + { + let mut config = repo_a.config().unwrap(); + config.set_str("user.email", "alice@example.com").unwrap(); + config.set_str("user.name", "Alice").unwrap(); + } + repo_a + .remote("origin", bare_dir.path().to_str().unwrap()) + .unwrap(); + let sig_a = git2::Signature::now("Alice", "alice@example.com").unwrap(); + let tree_oid = repo_a.treebuilder(None).unwrap().write().unwrap(); + let tree = repo_a.find_tree(tree_oid).unwrap(); + repo_a + .commit(Some("HEAD"), &sig_a, &sig_a, "initial", &tree, &[]) + .unwrap(); + + let repo_b = git2::Repository::init(repo_b_dir.path()).unwrap(); + { + let mut config = repo_b.config().unwrap(); + config.set_str("user.email", "bob@example.com").unwrap(); + config.set_str("user.name", "Bob").unwrap(); + } + repo_b + .remote("origin", bare_dir.path().to_str().unwrap()) + .unwrap(); + let sig_b = git2::Signature::now("Bob", "bob@example.com").unwrap(); + let tree_oid_b = repo_b.treebuilder(None).unwrap().write().unwrap(); + let tree_b = repo_b.find_tree(tree_oid_b).unwrap(); + repo_b + .commit(Some("HEAD"), &sig_b, &sig_b, "initial", &tree_b, &[]) + .unwrap(); + + // === Step 1: User A sets initial data and serializes === + harness::gmeta(repo_a_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + "alice@example.com", + ]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let a_oid = repo_a + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + copy_meta_objects(&repo_a, &bare_dir); + git2::Repository::open_bare(bare_dir.path()) + .unwrap() + .reference("refs/meta/local/main", a_oid, true, "push A") + .unwrap(); + + // === Step 2: User B pulls, materializes, modifies, serializes === + copy_meta_objects_from(&bare_dir, &repo_b); + repo_b + .reference("refs/meta/origin", a_oid, true, "fetch") + .unwrap(); + + harness::gmeta(repo_b_dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(repo_b_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + "bob@example.com", + ]) + .assert() + .success(); + + harness::gmeta(repo_b_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let b_oid = repo_b + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + copy_meta_objects(&repo_b, &bare_dir); + git2::Repository::open_bare(bare_dir.path()) + .unwrap() + .reference("refs/meta/local/main", b_oid, true, "push B") + .unwrap(); + + // === Step 3: User A modifies the same key AFTER B, serializes, then materializes === + harness::gmeta(repo_a_dir.path()) + .args([ + "set", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + "tom@example.com", + ]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + copy_meta_objects_from(&bare_dir, &repo_a); + repo_a + .reference("refs/meta/origin", b_oid, true, "fetch B") + .unwrap(); + + harness::gmeta(repo_a_dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(repo_a_dir.path()) + .args([ + "get", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + ]) + .assert() + .success() + .stdout(predicate::str::contains("tom@example.com")); + + // === Now test the reverse: B materializes A's newer changes === + let a_oid_new = repo_a + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + copy_meta_objects(&repo_a, &bare_dir); + git2::Repository::open_bare(bare_dir.path()) + .unwrap() + .reference("refs/meta/local/main", a_oid_new, true, "push A new") + .unwrap(); + + copy_meta_objects_from(&bare_dir, &repo_b); + repo_b + .reference("refs/meta/origin", a_oid_new, true, "fetch A new") + .unwrap(); + + harness::gmeta(repo_b_dir.path()) + .args(["materialize"]) + .assert() + .success(); + + harness::gmeta(repo_b_dir.path()) + .args([ + "get", + "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", + "testing:user", + ]) + .assert() + .success() + .stdout(predicate::str::contains("tom@example.com")); +} + +#[test] +fn dry_run_does_not_mutate_sqlite_or_ref() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "v1"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let first_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "v2"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let second_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + repo.reference("refs/meta/origin", second_oid, true, "test remote") + .unwrap(); + repo.reference("refs/meta/local/main", first_oid, true, "rollback local") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "stale"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["materialize", "--dry-run"]) + .assert() + .success() + .stdout(predicate::str::contains("dry-run: strategy=fast-forward")) + .stdout(predicate::str::contains("agent:model")); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("stale")) + .stdout(predicate::str::contains("v2").not()); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let local_after = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + assert_eq!(local_after, first_oid); +} + +#[test] +fn dry_run_reports_concurrent_add_conflict_resolution() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "base:key", "base"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let base_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "remote"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let remote_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + repo.reference("refs/meta/local/main", base_oid, true, "rollback to base") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "local"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let local_oid = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + repo.reference("refs/meta/origin", remote_oid, true, "set remote") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["materialize", "--dry-run"]) + .assert() + .success() + .stdout(predicate::str::contains("dry-run: strategy=three-way")) + .stdout(predicate::str::contains("reason=concurrent-add")) + .stdout(predicate::str::contains("agent:model")); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("local")); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let local_after = repo + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + assert_eq!(local_after, local_oid); +} + +#[test] +fn no_common_ancestor_uses_two_way_merge_remote_wins() { + let bare_dir = TempDir::new().unwrap(); + git2::Repository::init_bare(bare_dir.path()).unwrap(); + let (repo_a_dir, _sha_a) = setup_repo(); + let (repo_b_dir, _sha_b) = setup_repo(); + + harness::gmeta(repo_a_dir.path()) + .args(["set", "project", "agent:model", "local"]) + .assert() + .success(); + harness::gmeta(repo_a_dir.path()) + .args(["set", "project", "local:only", "keep-me"]) + .assert() + .success(); + harness::gmeta(repo_a_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo_a = git2::Repository::open(repo_a_dir.path()).unwrap(); + let a_oid = repo_a + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + + harness::gmeta(repo_b_dir.path()) + .args(["set", "project", "agent:model", "remote"]) + .assert() + .success(); + harness::gmeta(repo_b_dir.path()) + .args(["set", "project", "remote:only", "keep-too"]) + .assert() + .success(); + harness::gmeta(repo_b_dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo_b = git2::Repository::open(repo_b_dir.path()).unwrap(); + let b_oid = repo_b + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + + copy_meta_objects(&repo_b, &bare_dir); + git2::Repository::open_bare(bare_dir.path()) + .unwrap() + .reference("refs/meta/local/main", b_oid, true, "push B") + .unwrap(); + copy_meta_objects_from(&bare_dir, &repo_a); + repo_a + .reference("refs/meta/origin", b_oid, true, "fetch B into A") + .unwrap(); + + harness::gmeta(repo_a_dir.path()) + .args(["materialize", "--dry-run"]) + .assert() + .success() + .stdout(predicate::str::contains("no common ancestor")) + .stdout(predicate::str::contains( + "strategy=two-way-no-common-ancestor", + )) + .stdout(predicate::str::contains( + "reason=no-common-ancestor-local-wins", + )) + .stdout(predicate::str::contains("agent:model")); + + let a_after_dry_run = repo_a + .find_reference("refs/meta/local/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + assert_eq!(a_after_dry_run, a_oid); + + harness::gmeta(repo_a_dir.path()) + .args(["materialize"]) + .assert() + .success() + .stdout(predicate::str::contains("two-way merge")); + + harness::gmeta(repo_a_dir.path()) + .args(["get", "project", "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("local")) + .stdout(predicate::str::contains("remote").not()); + + harness::gmeta(repo_a_dir.path()) + .args(["get", "project", "local:only"]) + .assert() + .success() + .stdout(predicate::str::contains("keep-me")); + harness::gmeta(repo_a_dir.path()) + .args(["get", "project", "remote:only"]) + .assert() + .success() + .stdout(predicate::str::contains("keep-too")); +} diff --git a/tests/e2e/promisor.rs b/tests/e2e/promisor.rs new file mode 100644 index 0000000..6fae219 --- /dev/null +++ b/tests/e2e/promisor.rs @@ -0,0 +1,115 @@ +use predicates::prelude::*; + +use crate::harness::{ + self, setup_bare_with_history, setup_bare_with_history_retained, setup_bare_with_meta, + setup_repo, +}; + +#[test] +fn pull_inserts_promisor_entries() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_history(); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success() + .stderr(predicate::str::contains("Indexed 1 keys from history")); + + harness::gmeta(dir.path()) + .args(["get", "project", "testing"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")); + + // The historical key was pruned from the tip tree; get should not crash. + harness::gmeta(dir.path()) + .args(["get", "project", "old_key"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "project", "--json"]) + .assert() + .success() + .stdout(predicate::str::contains("testing")) + .stdout(predicate::str::contains("hello")); +} + +#[test] +fn promisor_hydration_from_tip_tree() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_history_retained(); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["get", "project", "old_key"]) + .assert() + .success() + .stdout(predicate::str::contains("old_value")); + + harness::gmeta(dir.path()) + .args(["get", "project", "testing"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")); +} + +#[test] +fn promisor_entry_not_serialized() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_history(); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let local_ref = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = local_ref.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let project_entry = tree.get_name("project").unwrap(); + let project_tree = repo.find_tree(project_entry.id()).unwrap(); + + assert!( + project_tree.get_name("testing").is_some(), + "tip key 'testing' should be in serialized tree" + ); + assert!( + project_tree.get_name("old_key").is_none(), + "promised key 'old_key' should NOT be in serialized tree" + ); +} + +#[test] +fn pull_tip_only_no_promisor_entries() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["pull"]) + .assert() + .success() + .stderr(predicate::str::contains("Indexed").not()); +} diff --git a/tests/e2e/push_pull.rs b/tests/e2e/push_pull.rs new file mode 100644 index 0000000..bc1e5f8 --- /dev/null +++ b/tests/e2e/push_pull.rs @@ -0,0 +1,247 @@ +use predicates::prelude::*; + +use crate::harness::{self, commit_target, setup_bare_with_meta, setup_repo}; + +#[test] +fn push_simple() { + let (dir, sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + let target = commit_target(&sha); + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["push"]) + .assert() + .success() + .stdout(predicate::str::contains("Pushed metadata to meta")); + + let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); + let commit = bare + .find_reference("refs/meta/main") + .unwrap() + .peel_to_commit() + .unwrap(); + assert_eq!( + commit.parent_count(), + 1, + "pushed commit should have exactly 1 parent (no merge commits)" + ); +} + +#[test] +fn push_up_to_date() { + let (dir, sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + let target = commit_target(&sha); + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()).args(["push"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["push"]) + .assert() + .success() + .stdout(predicate::str::contains("Everything up-to-date")); +} + +#[test] +fn push_commit_message_format() { + let (dir, sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + let target = commit_target(&sha); + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["set", &target, "agent:cost", "0.05"]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["push"]).assert().success(); + + let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); + let commit = bare + .find_reference("refs/meta/main") + .unwrap() + .peel_to_commit() + .unwrap(); + let msg = commit.message().unwrap(); + assert!( + msg.contains("gmeta: serialize"), + "commit message should start with 'gmeta: serialize', got: {}", + msg + ); + assert!( + msg.contains("agent:model"), + "commit message should contain changed key, got: {}", + msg + ); +} + +#[test] +fn push_conflict_produces_no_merge_commits() { + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + let (dir_a, sha_a) = setup_repo(); + let (dir_b, sha_b) = setup_repo(); + + harness::gmeta(dir_a.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir_b.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + + harness::gmeta(dir_a.path()) + .args(["pull"]) + .assert() + .success(); + harness::gmeta(dir_b.path()) + .args(["pull"]) + .assert() + .success(); + + let target_a = commit_target(&sha_a); + harness::gmeta(dir_a.path()) + .args(["set", &target_a, "from:a", "value-a"]) + .assert() + .success(); + harness::gmeta(dir_a.path()) + .args(["push"]) + .assert() + .success(); + + let target_b = commit_target(&sha_b); + harness::gmeta(dir_b.path()) + .args(["set", &target_b, "from:b", "value-b"]) + .assert() + .success(); + harness::gmeta(dir_b.path()) + .args(["push"]) + .assert() + .success(); + + let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); + let tip = bare + .find_reference("refs/meta/main") + .unwrap() + .peel_to_commit() + .unwrap(); + + let mut revwalk = bare.revwalk().unwrap(); + revwalk.push(tip.id()).unwrap(); + for oid in revwalk { + let oid = oid.unwrap(); + let commit = bare.find_commit(oid).unwrap(); + assert!( + commit.parent_count() <= 1, + "commit {} has {} parents — merge commits are not allowed in pushed history", + &commit.id().to_string()[..8], + commit.parent_count() + ); + } +} + +#[test] +fn pull_simple() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["get", "project", "testing"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")); +} + +#[test] +fn pull_up_to_date() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["pull"]) + .assert() + .success() + .stdout(predicate::str::contains("Already up-to-date")); +} + +#[test] +fn pull_merges_with_local_data() { + let (dir, sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + let target = commit_target(&sha); + harness::gmeta(dir.path()) + .args(["set", &target, "local:key", "local-value"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + harness::gmeta(dir.path()).args(["pull"]).assert().success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "local:key"]) + .assert() + .success() + .stdout(predicate::str::contains("local-value")); + + harness::gmeta(dir.path()) + .args(["get", "project", "testing"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")); +} diff --git a/tests/e2e/remote.rs b/tests/e2e/remote.rs new file mode 100644 index 0000000..ebcf4d0 --- /dev/null +++ b/tests/e2e/remote.rs @@ -0,0 +1,109 @@ +use predicates::prelude::*; +use tempfile::TempDir; + +use crate::harness::{self, setup_bare_with_meta, setup_repo}; + +#[test] +fn remote_add_no_meta_refs() { + let (dir, _sha) = setup_repo(); + let bare_dir = TempDir::new().unwrap(); + git2::Repository::init_bare(bare_dir.path()).unwrap(); + + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .failure() + .stderr(predicate::str::contains("no metadata refs found")); +} + +#[test] +fn remote_add_meta_refs_in_different_namespace() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("altmeta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .failure() + .stderr(predicate::str::contains("refs/altmeta/main")) + .stderr(predicate::str::contains("--namespace=altmeta")); +} + +#[test] +fn remote_add_with_namespace_override() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("altmeta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path, "--namespace=altmeta"]) + .assert() + .success() + .stdout(predicate::str::contains("Added meta remote")); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let config = repo.config().unwrap(); + let fetch = config.get_string("remote.meta.fetch").unwrap(); + assert!( + fetch.contains("refs/altmeta/"), + "fetch refspec should use altmeta namespace, got: {}", + fetch + ); + let meta_ns = config.get_string("remote.meta.metanamespace").unwrap(); + assert_eq!(meta_ns, "altmeta"); +} + +#[test] +fn remote_add_shorthand_url_expansion() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["remote", "add", "nonexistent-user-xyz/nonexistent-repo-xyz"]) + .assert() + .success() + .stdout(predicate::str::contains( + "git@github.com:nonexistent-user-xyz/nonexistent-repo-xyz.git", + )); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let config = repo.config().unwrap(); + let url = config.get_string("remote.meta.url").unwrap(); + assert_eq!( + url, + "git@github.com:nonexistent-user-xyz/nonexistent-repo-xyz.git" + ); +} + +#[test] +fn remote_list_and_remove() { + let (dir, _sha) = setup_repo(); + let bare_dir = setup_bare_with_meta("meta"); + let bare_path = bare_dir.path().to_str().unwrap(); + + harness::gmeta(dir.path()) + .args(["remote", "add", bare_path]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["remote", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("meta\t")) + .stdout(predicate::str::contains(bare_path)); + + harness::gmeta(dir.path()) + .args(["remote", "remove", "meta"]) + .assert() + .success() + .stdout(predicate::str::contains("Removed meta remote")); + + harness::gmeta(dir.path()) + .args(["remote", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("No metadata remotes configured")); +} diff --git a/tests/e2e/serialize.rs b/tests/e2e/serialize.rs new file mode 100644 index 0000000..1c9260c --- /dev/null +++ b/tests/e2e/serialize.rs @@ -0,0 +1,281 @@ +use predicates::prelude::*; + +use crate::harness::{self, commit_target, setup_repo, target_fanout}; + +#[test] +fn serialize_creates_ref() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success() + .stdout(predicate::str::contains("refs/meta/local/main")); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let first2 = &sha[..2]; + let expected_path = format!("commit/{}/{}/agent/model/__value", first2, sha); + + let mut found = false; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path == expected_path { + let blob = repo.find_blob(entry.id()).unwrap(); + let content = std::str::from_utf8(blob.content()).unwrap(); + assert_eq!(content, "claude-4.6"); + found = true; + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + assert!(found, "expected tree path not found in serialized tree"); +} + +#[test] +fn serialize_path_target_uses_raw_segments_and_separator() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "path:src/__generated/file.rs", "owner", "schacon"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let expected_path = "path/src/~__generated/file.rs/__target__/owner/__value"; + + let mut found = false; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path == expected_path { + let blob = repo.find_blob(entry.id()).unwrap(); + let content = std::str::from_utf8(blob.content()).unwrap(); + assert_eq!(content, "schacon"); + found = true; + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + assert!(found, "expected tree path not found in serialized tree"); +} + +#[test] +fn serialize_list_values() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + "branch:sc-branch-1-deadbeef", + "agent:chat", + r#"["how's it going","pretty good"]"#, + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let mut list_entries = Vec::new(); + let fanout = target_fanout("sc-branch-1-deadbeef"); + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path.starts_with(&format!( + "branch/{}/sc-branch-1-deadbeef/agent/chat/__list/", + fanout + )) && entry.kind() == Some(git2::ObjectType::Blob) + { + list_entries.push(full_path); + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + assert_eq!( + list_entries.len(), + 2, + "expected 2 list entries, got: {:?}", + list_entries + ); + + for entry_path in &list_entries { + let filename = entry_path.rsplit('/').next().unwrap(); + let parts: Vec<&str> = filename.split('-').collect(); + assert_eq!( + parts.len(), + 2, + "list entry should be timestamp-hash: {}", + filename + ); + assert!( + parts[0].chars().all(|c| c.is_ascii_digit()), + "first part should be digits: {}", + filename + ); + assert_eq!( + parts[1].len(), + 5, + "hash part should be 5 chars: {}", + filename + ); + } +} + +#[test] +fn serialize_empty() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success() + .stdout(predicate::str::contains("no metadata to serialize")); +} + +#[test] +fn serialize_list_uses_stored_timestamp() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "list", + "branch:sc-branch-1-deadbeef", + "agent:chat", + r#"["hello","world"]"#, + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let first_entries = collect_list_entry_names(&repo); + assert_eq!(first_entries.len(), 2); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let second_entries = collect_list_entry_names(&repo); + assert_eq!(second_entries.len(), 2); + + assert_eq!( + first_entries, second_entries, + "list entry names should be stable across serializations when data is unchanged" + ); +} + +#[test] +fn serialize_rm_writes_tombstone_blob() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["rm", &target, "agent:model"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let first2 = &sha[..2]; + let value_path = format!("commit/{}/{}/agent/model/__value", first2, sha); + let tombstone_path = format!( + "commit/{}/{}/__tombstones/agent/model/__deleted", + first2, sha + ); + + let mut found_value = false; + let mut found_tombstone = false; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path == value_path { + found_value = true; + } + if full_path == tombstone_path { + found_tombstone = true; + let blob = repo.find_blob(entry.id()).unwrap(); + let payload: serde_json::Value = serde_json::from_slice(blob.content()).unwrap(); + assert_eq!(payload["email"], "test@example.com"); + assert!(payload["timestamp"].as_i64().is_some()); + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + assert!(!found_value, "value blob should be removed after rm"); + assert!(found_tombstone, "tombstone blob should be serialized"); +} + +fn collect_list_entry_names(repo: &git2::Repository) -> Vec { + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + + let mut entries = Vec::new(); + let fanout = target_fanout("sc-branch-1-deadbeef"); + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path.starts_with(&format!( + "branch/{}/sc-branch-1-deadbeef/agent/chat/__list/", + fanout + )) && entry.kind() == Some(git2::ObjectType::Blob) + { + let name = entry.name().unwrap().to_string(); + entries.push(name); + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + entries.sort(); + entries +} diff --git a/tests/e2e/set_get.rs b/tests/e2e/set_get.rs new file mode 100644 index 0000000..72e1271 --- /dev/null +++ b/tests/e2e/set_get.rs @@ -0,0 +1,478 @@ +use predicates::prelude::*; + +use crate::harness::{self, commit_target, setup_repo}; + +#[test] +fn set_and_get_string() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target]) + .assert() + .success() + .stdout(predicate::str::contains("agent:model")) + .stdout(predicate::str::contains("claude-4.6")); +} + +#[test] +fn set_and_get_with_partial_sha() { + let (dir, sha) = setup_repo(); + let full_target = commit_target(&sha); + let partial_target = commit_target(&sha[..8]); + + harness::gmeta(dir.path()) + .args(["set", &partial_target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &full_target]) + .assert() + .success() + .stdout(predicate::str::contains("claude-4.6")); + + harness::gmeta(dir.path()) + .args(["get", &partial_target]) + .assert() + .success() + .stdout(predicate::str::contains("claude-4.6")); +} + +#[test] +fn set_and_get_specific_key() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:provider", "anthropic"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-4.6")) + .stdout(predicate::str::contains("provider").not()); +} + +#[test] +fn set_and_get_json() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:provider", "anthropic"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "--json", &target]) + .assert() + .success() + .stdout(predicate::str::contains("\"model\": \"claude-4.6\"")) + .stdout(predicate::str::contains("\"provider\": \"anthropic\"")); +} + +#[test] +fn json_with_authorship() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "--json", "--with-authorship", &target]) + .assert() + .success() + .stdout(predicate::str::contains("\"value\": \"claude-4.6\"")) + .stdout(predicate::str::contains("\"author\": \"test@example.com\"")) + .stdout(predicate::str::contains("\"timestamp\"")); +} + +#[test] +fn partial_key_matching() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:provider", "anthropic"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "other:key", "value"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent"]) + .assert() + .success() + .stdout(predicate::str::contains("agent:model")) + .stdout(predicate::str::contains("agent:provider")) + .stdout(predicate::str::contains("other:key").not()); +} + +#[test] +fn partial_key_matching_commit_namespace_example() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-opus-4-6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:provider", "anthropic"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:prompt", "Make me a sandwich"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:transcript", "..."]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent"]) + .assert() + .success() + .stdout(predicate::str::contains("agent:model")) + .stdout(predicate::str::contains("claude-opus-4-6")) + .stdout(predicate::str::contains("agent:provider")) + .stdout(predicate::str::contains("anthropic")) + .stdout(predicate::str::contains("agent:prompt")) + .stdout(predicate::str::contains("Make me a sandwich")) + .stdout(predicate::str::contains("agent:transcript")); +} + +#[test] +fn rm_removes_value() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["rm", &target, "agent:model"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::is_empty()); +} + +#[test] +fn upsert_overwrites() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "v1"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "v2"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", &target, "agent:model"]) + .assert() + .success() + .stdout(predicate::str::contains("v2")) + .stdout(predicate::str::contains("v1").not()); +} + +#[test] +fn path_target() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "path:src/main.rs", "review:status", "approved"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "path:src/main.rs"]) + .assert() + .success() + .stdout(predicate::str::contains( + "src/main.rs;review:status approved", + )); +} + +#[test] +fn path_target_tree_lookup() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "path:src/git", "owner", "schacon"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["set", "path:src/observability", "owner", "caleb"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["set", "path:src/metrics", "owner", "kiril"]) + .assert() + .success(); + harness::gmeta(dir.path()) + .args(["set", "path:srcx/metrics", "owner", "nope"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "path:src", "owner"]) + .assert() + .success() + .stdout(predicate::str::contains("src/git;owner")) + .stdout(predicate::str::contains("schacon")) + .stdout(predicate::str::contains("src/observability;owner")) + .stdout(predicate::str::contains("caleb")) + .stdout(predicate::str::contains("src/metrics;owner")) + .stdout(predicate::str::contains("kiril")) + .stdout(predicate::str::contains("srcx/metrics;owner").not()); +} + +#[test] +fn change_id_target() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args([ + "set", + "change-id:550e8400-e29b-41d4-a716-446655440000", + "status", + "merged", + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "change-id:550e8400-e29b-41d4-a716-446655440000"]) + .assert() + .success() + .stdout(predicate::str::contains("status")) + .stdout(predicate::str::contains("merged")); +} + +#[test] +fn project_target() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "project", "name", "my-project"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "project", "name"]) + .assert() + .success() + .stdout(predicate::str::contains("my-project")); +} + +#[test] +fn invalid_target_type() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "unknown:abc123", "key", "value"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown target type")); +} + +#[test] +fn target_value_too_short() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args(["set", "commit:ab", "key", "value"]) + .assert() + .failure() + .stderr(predicate::str::contains("at least 3 characters")); +} + +#[test] +fn custom_namespace() { + let (dir, sha) = setup_repo(); + let target = commit_target(&sha); + + let repo = git2::Repository::open(dir.path()).unwrap(); + repo.config() + .unwrap() + .set_str("meta.namespace", "notes") + .unwrap(); + drop(repo); + + harness::gmeta(dir.path()) + .args(["set", &target, "agent:model", "claude-4.6"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success() + .stdout(predicate::str::contains("refs/notes/local/main")); + + let repo = git2::Repository::open(dir.path()).unwrap(); + assert!(repo.find_reference("refs/notes/local/main").is_ok()); + assert!(repo.find_reference("refs/meta/local/main").is_err()); +} + +#[test] +fn set_add_and_rm() { + let (dir, _sha) = setup_repo(); + let target = "branch:sc-branch-1-deadbeef"; + + harness::gmeta(dir.path()) + .args(["set:add", target, "reviewer", "alice@example.com"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set:add", target, "reviewer", "bob@example.com"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", target, "reviewer"]) + .assert() + .success() + .stdout(predicate::str::contains("alice@example.com")) + .stdout(predicate::str::contains("bob@example.com")); + + harness::gmeta(dir.path()) + .args(["set:rm", target, "reviewer", "alice@example.com"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", target, "reviewer"]) + .assert() + .success() + .stdout(predicate::str::contains("bob@example.com")) + .stdout(predicate::str::contains("alice@example.com").not()); +} + +#[test] +fn set_add_deduplicates_members() { + let (dir, _sha) = setup_repo(); + let target = "branch:sc-branch-1-deadbeef"; + + harness::gmeta(dir.path()) + .args(["set:add", target, "reviewer", "alice@example.com"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["set:add", target, "reviewer", "alice@example.com"]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", target, "reviewer"]) + .assert() + .success() + .stdout(predicate::str::contains("alice@example.com").count(1)); +} + +#[test] +fn set_type_round_trips_and_serializes_members() { + let (dir, _sha) = setup_repo(); + + harness::gmeta(dir.path()) + .args([ + "set", + "-t", + "set", + "branch:sc-branch-1-deadbeef", + "reviewer", + r#"["alice@example.com","bob@example.com","alice@example.com"]"#, + ]) + .assert() + .success(); + + harness::gmeta(dir.path()) + .args(["get", "branch:sc-branch-1-deadbeef", "reviewer"]) + .assert() + .success() + .stdout(predicate::str::contains("alice@example.com")) + .stdout(predicate::str::contains("bob@example.com")); + + harness::gmeta(dir.path()) + .args(["serialize"]) + .assert() + .success(); + + let repo = git2::Repository::open(dir.path()).unwrap(); + let reference = repo.find_reference("refs/meta/local/main").unwrap(); + let commit = reference.peel_to_commit().unwrap(); + let tree = commit.tree().unwrap(); + let fanout = harness::target_fanout("sc-branch-1-deadbeef"); + + let mut set_members = Vec::new(); + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let full_path = format!("{}{}", root, entry.name().unwrap_or("")); + if full_path.starts_with(&format!( + "branch/{}/sc-branch-1-deadbeef/reviewer/__set/", + fanout + )) && entry.kind() == Some(git2::ObjectType::Blob) + { + let tail = full_path + .strip_prefix(&format!( + "branch/{}/sc-branch-1-deadbeef/reviewer/__set/", + fanout + )) + .unwrap(); + if !tail.contains('/') { + set_members.push(full_path); + } + } + git2::TreeWalkResult::Ok + }) + .unwrap(); + + assert_eq!(set_members.len(), 2); +} diff --git a/tests/fixtures/bare-with-history-retained.sh b/tests/fixtures/bare-with-history-retained.sh new file mode 100755 index 0000000..f8e2377 --- /dev/null +++ b/tests/fixtures/bare-with-history-retained.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Creates a bare repo with 2 commits on refs/meta/main: +# Commit 1 (older): project/old_key/__value = "old_value" +# Commit 2 (tip): project/old_key/__value = "old_value" +# project/testing/__value = "hello" +# +# Unlike bare-with-history.sh, the tip commit retains old_key in its tree. +# Tip commit message only mentions 'testing' (old_key was added in commit 1). +set -eu -o pipefail + +git init --bare + +# --- Commit 1: project/old_key/__value = "old_value" --- +BLOB1=$(echo -n '"old_value"' | git hash-object -w --stdin) +VAL_TREE1=$(printf '100644 blob %s\t__value\n' "$BLOB1" | git mktree) +PROJ_TREE1=$(printf '040000 tree %s\told_key\n' "$VAL_TREE1" | git mktree) +ROOT_TREE1=$(printf '040000 tree %s\tproject\n' "$PROJ_TREE1" | git mktree) + +COMMIT1=$(printf 'gmeta: serialize (1 changes)\n\nA\tproject\told_key' \ + | git commit-tree "$ROOT_TREE1") + +# --- Commit 2 (tip): both old_key and testing --- +BLOB2=$(echo -n '"hello"' | git hash-object -w --stdin) +VAL_TREE2=$(printf '100644 blob %s\t__value\n' "$BLOB2" | git mktree) +# Build project tree with both entries +PROJ_TREE2=$(printf '040000 tree %s\told_key\n040000 tree %s\ttesting\n' \ + "$VAL_TREE1" "$VAL_TREE2" | git mktree) +ROOT_TREE2=$(printf '040000 tree %s\tproject\n' "$PROJ_TREE2" | git mktree) + +COMMIT2=$(printf 'gmeta: serialize (1 changes)\n\nA\tproject\ttesting' \ + | git commit-tree "$ROOT_TREE2" -p "$COMMIT1") + +git update-ref "refs/meta/main" "$COMMIT2" diff --git a/tests/fixtures/bare-with-history.sh b/tests/fixtures/bare-with-history.sh new file mode 100755 index 0000000..2b80110 --- /dev/null +++ b/tests/fixtures/bare-with-history.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Creates a bare repo with 2 commits on refs/meta/main: +# Commit 1 (older): project/old_key/__value = "old_value" +# Commit 2 (tip): project/testing/__value = "hello" (old_key removed) +# +# Commit messages include the changelog format gmeta expects: +# Commit 1: "gmeta: serialize (1 changes)\n\nA\tproject\told_key" +# Commit 2: "gmeta: serialize (1 changes)\n\nA\tproject\ttesting" +set -eu -o pipefail + +git init --bare + +# --- Commit 1: project/old_key/__value = "old_value" --- +BLOB1=$(echo -n '"old_value"' | git hash-object -w --stdin) +VAL_TREE1=$(printf '100644 blob %s\t__value\n' "$BLOB1" | git mktree) +PROJ_TREE1=$(printf '040000 tree %s\told_key\n' "$VAL_TREE1" | git mktree) +ROOT_TREE1=$(printf '040000 tree %s\tproject\n' "$PROJ_TREE1" | git mktree) + +COMMIT1=$(printf 'gmeta: serialize (1 changes)\n\nA\tproject\told_key' \ + | git commit-tree "$ROOT_TREE1") + +# --- Commit 2 (tip): project/testing/__value = "hello" --- +BLOB2=$(echo -n '"hello"' | git hash-object -w --stdin) +VAL_TREE2=$(printf '100644 blob %s\t__value\n' "$BLOB2" | git mktree) +PROJ_TREE2=$(printf '040000 tree %s\ttesting\n' "$VAL_TREE2" | git mktree) +ROOT_TREE2=$(printf '040000 tree %s\tproject\n' "$PROJ_TREE2" | git mktree) + +COMMIT2=$(printf 'gmeta: serialize (1 changes)\n\nA\tproject\ttesting' \ + | git commit-tree "$ROOT_TREE2" -p "$COMMIT1") + +git update-ref "refs/meta/main" "$COMMIT2" diff --git a/tests/fixtures/bare-with-meta.sh b/tests/fixtures/bare-with-meta.sh new file mode 100755 index 0000000..4ae0c79 --- /dev/null +++ b/tests/fixtures/bare-with-meta.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Creates a bare repo with refs/${NS}/main containing: +# project/testing/__value = "hello" +# +# Usage: bare-with-meta.sh [namespace] +# Default namespace: meta +set -eu -o pipefail + +NS="${1:-meta}" + +git init --bare + +# Build the tree structure expected by gmeta: +# project/ +# testing/ +# __value (blob: "hello") +BLOB=$(echo -n '"hello"' | git hash-object -w --stdin) +VALUE_TREE=$(printf '100644 blob %s\t__value\n' "$BLOB" | git mktree) +PROJECT_TREE=$(printf '040000 tree %s\ttesting\n' "$VALUE_TREE" | git mktree) +ROOT_TREE=$(printf '040000 tree %s\tproject\n' "$PROJECT_TREE" | git mktree) + +COMMIT=$(git commit-tree "$ROOT_TREE" -m "initial meta") +git update-ref "refs/$NS/main" "$COMMIT" diff --git a/tests/fixtures/basic-repo.sh b/tests/fixtures/basic-repo.sh new file mode 100755 index 0000000..ddf7ffb --- /dev/null +++ b/tests/fixtures/basic-repo.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -eu -o pipefail + +git init +echo "initial content" > README +git add . +git commit -m "initial" diff --git a/tests/integration.rs b/tests/integration.rs deleted file mode 100644 index 834b249..0000000 --- a/tests/integration.rs +++ /dev/null @@ -1,2355 +0,0 @@ -use assert_cmd::Command; -use predicates::prelude::*; -use sha1::{Digest, Sha1}; -use std::path::Path; -use tempfile::TempDir; - -/// Create a temporary git repo and return the TempDir handle + the initial commit SHA. -fn setup_repo() -> (TempDir, String) { - let dir = TempDir::new().unwrap(); - let repo = git2::Repository::init(dir.path()).unwrap(); - - // Set up user config so commands can read email - let mut config = repo.config().unwrap(); - config.set_str("user.email", "test@example.com").unwrap(); - config.set_str("user.name", "Test User").unwrap(); - - // Create an initial commit so the repo is valid - let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); - let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); - let tree = repo.find_tree(tree_oid).unwrap(); - let commit_oid = repo - .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]) - .unwrap(); - - (dir, commit_oid.to_string()) -} - -fn gmeta(dir: &Path) -> Command { - let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("gmeta"); - cmd.current_dir(dir); - cmd -} - -/// Helper to build a commit target string from a full SHA. -fn commit_target(sha: &str) -> String { - format!("commit:{}", sha) -} - -fn target_fanout(value: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(value.as_bytes()); - let hash = format!("{:x}", hasher.finalize()); - hash[..2].to_string() -} - -#[test] -fn test_set_and_get_string() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target]) - .assert() - .success() - .stdout(predicate::str::contains("agent:model")) - .stdout(predicate::str::contains("claude-4.6")); -} - -#[test] -fn test_set_and_get_with_partial_sha() { - let (dir, sha) = setup_repo(); - let full_target = commit_target(&sha); - let partial_target = commit_target(&sha[..8]); - - // Set with partial SHA - gmeta(dir.path()) - .args(["set", &partial_target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - // Get with full SHA should find it (was expanded on set) - gmeta(dir.path()) - .args(["get", &full_target]) - .assert() - .success() - .stdout(predicate::str::contains("claude-4.6")); - - // Get with partial SHA should also find it - gmeta(dir.path()) - .args(["get", &partial_target]) - .assert() - .success() - .stdout(predicate::str::contains("claude-4.6")); -} - -#[test] -fn test_set_and_get_specific_key() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:provider", "anthropic"]) - .assert() - .success(); - - // Get specific key - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::contains("claude-4.6")) - .stdout(predicate::str::contains("provider").not()); -} - -#[test] -fn test_set_and_get_json() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:provider", "anthropic"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "--json", &target]) - .assert() - .success() - .stdout(predicate::str::contains("\"model\": \"claude-4.6\"")) - .stdout(predicate::str::contains("\"provider\": \"anthropic\"")); -} - -#[test] -fn test_json_with_authorship() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "--json", "--with-authorship", &target]) - .assert() - .success() - .stdout(predicate::str::contains("\"value\": \"claude-4.6\"")) - .stdout(predicate::str::contains("\"author\": \"test@example.com\"")) - .stdout(predicate::str::contains("\"timestamp\"")); -} - -#[test] -fn test_partial_key_matching() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:provider", "anthropic"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "other:key", "value"]) - .assert() - .success(); - - // Partial key "agent" should match both agent: keys - gmeta(dir.path()) - .args(["get", &target, "agent"]) - .assert() - .success() - .stdout(predicate::str::contains("agent:model")) - .stdout(predicate::str::contains("agent:provider")) - .stdout(predicate::str::contains("other:key").not()); -} - -#[test] -fn test_partial_key_matching_commit_namespace_example() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-opus-4-6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:provider", "anthropic"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:prompt", "Make me a sandwich"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:transcript", "..."]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "agent"]) - .assert() - .success() - .stdout(predicate::str::contains("agent:model")) - .stdout(predicate::str::contains("claude-opus-4-6")) - .stdout(predicate::str::contains("agent:provider")) - .stdout(predicate::str::contains("anthropic")) - .stdout(predicate::str::contains("agent:prompt")) - .stdout(predicate::str::contains("Make me a sandwich")) - .stdout(predicate::str::contains("agent:transcript")); -} - -#[test] -fn test_rm_removes_value() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["rm", &target, "agent:model"]) - .assert() - .success(); - - // Should produce no output now - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::is_empty()); -} - -#[test] -fn test_list_push() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["list:push", &target, "tags", "first"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["list:push", &target, "tags", "second"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "tags"]) - .assert() - .success() - .stdout(predicate::str::contains("first")) - .stdout(predicate::str::contains("second")); -} - -#[test] -fn test_list_push_converts_string_to_list() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "note", "original"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["list:push", &target, "note", "appended"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "note"]) - .assert() - .success() - .stdout(predicate::str::contains("original")) - .stdout(predicate::str::contains("appended")); -} - -#[test] -fn test_list_pop() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["list:push", &target, "tags", "a"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["list:push", &target, "tags", "b"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["list:pop", &target, "tags", "b"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "tags"]) - .assert() - .success() - .stdout(predicate::str::contains("a")) - .stdout(predicate::str::contains("b").not()); -} - -#[test] -fn test_set_list_type() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args([ - "set", - "-t", - "list", - &target, - "items", - r#"["hello","world"]"#, - ]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "items"]) - .assert() - .success() - .stdout(predicate::str::contains("hello")) - .stdout(predicate::str::contains("world")); -} - -#[test] -fn test_serialize_creates_ref() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["serialize"]) - .assert() - .success() - .stdout(predicate::str::contains("refs/meta/local/main")); - - // Verify the ref exists and contains the right tree structure - let repo = git2::Repository::open(dir.path()).unwrap(); - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - // Build the expected path from the full SHA - let first2 = &sha[..2]; - let expected_path = format!("commit/{}/{}/agent/model/__value", first2, sha); - - // Walk the tree and verify structure - let mut found = false; - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path == expected_path { - // Verify blob content - let blob = repo.find_blob(entry.id()).unwrap(); - let content = std::str::from_utf8(blob.content()).unwrap(); - assert_eq!(content, "claude-4.6"); - found = true; - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - assert!(found, "expected tree path not found in serialized tree"); -} - -#[test] -fn test_serialize_path_target_uses_raw_segments_and_separator() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "path:src/__generated/file.rs", "owner", "schacon"]) - .assert() - .success(); - - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - let expected_path = "path/src/~__generated/file.rs/__target__/owner/__value"; - - let mut found = false; - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path == expected_path { - let blob = repo.find_blob(entry.id()).unwrap(); - let content = std::str::from_utf8(blob.content()).unwrap(); - assert_eq!(content, "schacon"); - found = true; - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - assert!(found, "expected tree path not found in serialized tree"); -} - -#[test] -fn test_project_target() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "project", "name", "my-project"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "project", "name"]) - .assert() - .success() - .stdout(predicate::str::contains("my-project")); -} - -#[test] -fn test_invalid_target_type() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "unknown:abc123", "key", "value"]) - .assert() - .failure() - .stderr(predicate::str::contains("unknown target type")); -} - -#[test] -fn test_target_value_too_short() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "commit:ab", "key", "value"]) - .assert() - .failure() - .stderr(predicate::str::contains("at least 3 characters")); -} - -#[test] -fn test_serialize_list_values() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args([ - "set", - "-t", - "list", - "branch:sc-branch-1-deadbeef", - "agent:chat", - r#"["how's it going","pretty good"]"#, - ]) - .assert() - .success(); - - gmeta(dir.path()).args(["serialize"]).assert().success(); - - // Verify tree structure has list entries with timestamp-hash format - let repo = git2::Repository::open(dir.path()).unwrap(); - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - let mut list_entries = Vec::new(); - let fanout = target_fanout("sc-branch-1-deadbeef"); - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path.starts_with(&format!( - "branch/{}/sc-branch-1-deadbeef/agent/chat/__list/", - fanout - )) && entry.kind() == Some(git2::ObjectType::Blob) - { - list_entries.push(full_path); - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - assert_eq!( - list_entries.len(), - 2, - "expected 2 list entries, got: {:?}", - list_entries - ); - - // Verify entry names follow timestamp-hash format - for entry_path in &list_entries { - let filename = entry_path.rsplit('/').next().unwrap(); - let parts: Vec<&str> = filename.split('-').collect(); - assert_eq!( - parts.len(), - 2, - "list entry should be timestamp-hash: {}", - filename - ); - assert!( - parts[0].chars().all(|c| c.is_ascii_digit()), - "first part should be digits: {}", - filename - ); - assert_eq!( - parts[1].len(), - 5, - "hash part should be 5 chars: {}", - filename - ); - } -} - -#[test] -fn test_upsert_overwrites() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "v1"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "v2"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::contains("v2")) - .stdout(predicate::str::contains("v1").not()); -} - -#[test] -fn test_path_target() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "path:src/main.rs", "review:status", "approved"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "path:src/main.rs"]) - .assert() - .success() - .stdout(predicate::str::contains( - "src/main.rs;review:status approved", - )); -} - -#[test] -fn test_path_target_tree_lookup() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["set", "path:src/git", "owner", "schacon"]) - .assert() - .success(); - gmeta(dir.path()) - .args(["set", "path:src/observability", "owner", "caleb"]) - .assert() - .success(); - gmeta(dir.path()) - .args(["set", "path:src/metrics", "owner", "kiril"]) - .assert() - .success(); - gmeta(dir.path()) - .args(["set", "path:srcx/metrics", "owner", "nope"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "path:src", "owner"]) - .assert() - .success() - .stdout(predicate::str::contains("src/git;owner")) - .stdout(predicate::str::contains("schacon")) - .stdout(predicate::str::contains("src/observability;owner")) - .stdout(predicate::str::contains("caleb")) - .stdout(predicate::str::contains("src/metrics;owner")) - .stdout(predicate::str::contains("kiril")) - .stdout(predicate::str::contains("srcx/metrics;owner").not()); -} - -#[test] -fn test_change_id_target() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args([ - "set", - "change-id:550e8400-e29b-41d4-a716-446655440000", - "status", - "merged", - ]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "change-id:550e8400-e29b-41d4-a716-446655440000"]) - .assert() - .success() - .stdout(predicate::str::contains("status")) - .stdout(predicate::str::contains("merged")); -} - -#[test] -fn test_serialize_empty() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args(["serialize"]) - .assert() - .success() - .stdout(predicate::str::contains("no metadata to serialize")); -} - -#[test] -fn test_serialize_list_uses_stored_timestamp() { - let (dir, _sha) = setup_repo(); - - // Set a list value - gmeta(dir.path()) - .args([ - "set", - "-t", - "list", - "branch:sc-branch-1-deadbeef", - "agent:chat", - r#"["hello","world"]"#, - ]) - .assert() - .success(); - - // Serialize once - gmeta(dir.path()).args(["serialize"]).assert().success(); - - // Collect list entry names from first serialization - let repo = git2::Repository::open(dir.path()).unwrap(); - let first_entries = collect_list_entry_names(&repo); - assert_eq!(first_entries.len(), 2); - - // Serialize again without any changes — timestamps should be identical - // because serialize uses the stored last_timestamp, not the current time - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let second_entries = collect_list_entry_names(&repo); - assert_eq!(second_entries.len(), 2); - - // Entry names (timestamp-hash) should be exactly the same both times - assert_eq!( - first_entries, second_entries, - "list entry names should be stable across serializations when data is unchanged" - ); -} - -#[test] -fn test_serialize_rm_writes_tombstone_blob() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["rm", &target, "agent:model"]) - .assert() - .success(); - - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - let first2 = &sha[..2]; - let value_path = format!("commit/{}/{}/agent/model/__value", first2, sha); - let tombstone_path = format!( - "commit/{}/{}/__tombstones/agent/model/__deleted", - first2, sha - ); - - let mut found_value = false; - let mut found_tombstone = false; - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path == value_path { - found_value = true; - } - if full_path == tombstone_path { - found_tombstone = true; - let blob = repo.find_blob(entry.id()).unwrap(); - let payload: serde_json::Value = serde_json::from_slice(blob.content()).unwrap(); - assert_eq!(payload["email"], "test@example.com"); - assert!(payload["timestamp"].as_i64().is_some()); - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - assert!(!found_value, "value blob should be removed after rm"); - assert!(found_tombstone, "tombstone blob should be serialized"); -} - -#[test] -fn test_materialize_fast_forward_applies_remote_removal() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - // Initial value and first serialized snapshot. - gmeta(dir.path()) - .args(["set", &target, "agent:model", "v1"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let first_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - drop(repo); - - // Remove the key and serialize second snapshot. - gmeta(dir.path()) - .args(["rm", &target, "agent:model"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let second_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - - // Simulate a fetched remote ref ahead of local. - repo.reference("refs/meta/origin", second_oid, true, "test remote") - .unwrap(); - // Move local ref back so materialize takes fast-forward path. - repo.reference("refs/meta/local/main", first_oid, true, "rollback local") - .unwrap(); - drop(repo); - - // Local SQLite still has a stale value to be removed. - gmeta(dir.path()) - .args(["set", &target, "agent:model", "stale"]) - .assert() - .success(); - - gmeta(dir.path()).args(["materialize"]).assert().success(); - - // Key should be removed after materialize. - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::is_empty()); -} - -#[test] -fn test_materialize_fast_forward_applies_remote_list_entry_removal() { - let (dir, _sha) = setup_repo(); - let target = "branch:sc-branch-1-deadbeef"; - - // Initial list and first serialized snapshot. - gmeta(dir.path()) - .args([ - "set", - "-t", - "list", - target, - "agent:chat", - r#"["a","b","c"]"#, - ]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let first_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - drop(repo); - - // Remove one list entry and serialize second snapshot. - gmeta(dir.path()) - .args(["list:pop", target, "agent:chat", "b"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let second_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - - // Simulate fetched remote ahead of local, then rewind local ref. - repo.reference("refs/meta/origin", second_oid, true, "test remote") - .unwrap(); - repo.reference("refs/meta/local/main", first_oid, true, "rollback local") - .unwrap(); - drop(repo); - - // Recreate stale local SQLite state with the removed item still present. - gmeta(dir.path()) - .args([ - "set", - "-t", - "list", - target, - "agent:chat", - r#"["a","b","c"]"#, - ]) - .assert() - .success(); - - gmeta(dir.path()).args(["materialize"]).assert().success(); - - // Removed list entry should be gone after materialize. - gmeta(dir.path()) - .args(["get", target, "agent:chat"]) - .assert() - .success() - .stdout(predicate::str::contains("a")) - .stdout(predicate::str::contains("c")) - .stdout(predicate::str::contains("b").not()); -} - -fn collect_list_entry_names(repo: &git2::Repository) -> Vec { - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - let mut entries = Vec::new(); - let fanout = target_fanout("sc-branch-1-deadbeef"); - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path.starts_with(&format!( - "branch/{}/sc-branch-1-deadbeef/agent/chat/__list/", - fanout - )) && entry.kind() == Some(git2::ObjectType::Blob) - { - let name = entry.name().unwrap().to_string(); - entries.push(name); - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - entries.sort(); - entries -} - -#[test] -fn test_set_add_and_rm() { - let (dir, _sha) = setup_repo(); - let target = "branch:sc-branch-1-deadbeef"; - - gmeta(dir.path()) - .args(["set:add", target, "reviewer", "alice@example.com"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set:add", target, "reviewer", "bob@example.com"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", target, "reviewer"]) - .assert() - .success() - .stdout(predicate::str::contains("alice@example.com")) - .stdout(predicate::str::contains("bob@example.com")); - - gmeta(dir.path()) - .args(["set:rm", target, "reviewer", "alice@example.com"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", target, "reviewer"]) - .assert() - .success() - .stdout(predicate::str::contains("bob@example.com")) - .stdout(predicate::str::contains("alice@example.com").not()); -} - -#[test] -fn test_set_add_deduplicates_members() { - let (dir, _sha) = setup_repo(); - let target = "branch:sc-branch-1-deadbeef"; - - gmeta(dir.path()) - .args(["set:add", target, "reviewer", "alice@example.com"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["set:add", target, "reviewer", "alice@example.com"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", target, "reviewer"]) - .assert() - .success() - .stdout(predicate::str::contains("alice@example.com").count(1)); -} - -#[test] -fn test_set_type_round_trips_and_serializes_members() { - let (dir, _sha) = setup_repo(); - - gmeta(dir.path()) - .args([ - "set", - "-t", - "set", - "branch:sc-branch-1-deadbeef", - "reviewer", - r#"["alice@example.com","bob@example.com","alice@example.com"]"#, - ]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["get", "branch:sc-branch-1-deadbeef", "reviewer"]) - .assert() - .success() - .stdout(predicate::str::contains("alice@example.com")) - .stdout(predicate::str::contains("bob@example.com")); - - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let reference = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = reference.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - let fanout = target_fanout("sc-branch-1-deadbeef"); - - let mut set_members = Vec::new(); - tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { - let full_path = format!("{}{}", root, entry.name().unwrap_or("")); - if full_path.starts_with(&format!( - "branch/{}/sc-branch-1-deadbeef/reviewer/__set/", - fanout - )) && entry.kind() == Some(git2::ObjectType::Blob) - { - let tail = full_path - .strip_prefix(&format!( - "branch/{}/sc-branch-1-deadbeef/reviewer/__set/", - fanout - )) - .unwrap(); - if !tail.contains('/') { - set_members.push(full_path); - } - } - git2::TreeWalkResult::Ok - }) - .unwrap(); - - assert_eq!(set_members.len(), 2); -} - -#[test] -fn test_custom_namespace() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - // Set meta.namespace to a custom value - let repo = git2::Repository::open(dir.path()).unwrap(); - repo.config() - .unwrap() - .set_str("meta.namespace", "notes") - .unwrap(); - drop(repo); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["serialize"]) - .assert() - .success() - .stdout(predicate::str::contains("refs/notes/local/main")); - - // Verify the ref exists under the custom namespace - let repo = git2::Repository::open(dir.path()).unwrap(); - assert!(repo.find_reference("refs/notes/local/main").is_ok()); - assert!(repo.find_reference("refs/meta/local/main").is_err()); -} - -/// Simulate the full round-trip described in the bug report: -/// -/// 1. User A sets metadata, serializes, "pushes" (we copy the ref) -/// 2. User B pulls, materializes (no new data), "pushes" the materialize commit -/// 3. User A pulls that back, overwrites a value locally, serializes -/// 4. User A materializes — the local change should NOT be overwritten -/// because the remote side didn't actually change that key. -#[test] -fn test_materialize_preserves_local_changes_over_stale_remote() { - // === Setup: two repos sharing via bare intermediary === - let bare_dir = TempDir::new().unwrap(); - let repo_a_dir = TempDir::new().unwrap(); - let repo_b_dir = TempDir::new().unwrap(); - - // Create bare repo - git2::Repository::init_bare(bare_dir.path()).unwrap(); - - // Clone into repo A - let repo_a = git2::Repository::init(repo_a_dir.path()).unwrap(); - { - let mut config = repo_a.config().unwrap(); - config.set_str("user.email", "alice@example.com").unwrap(); - config.set_str("user.name", "Alice").unwrap(); - } - repo_a - .remote("origin", bare_dir.path().to_str().unwrap()) - .unwrap(); - // Initial commit so repo is valid - let sig_a = git2::Signature::now("Alice", "alice@example.com").unwrap(); - let tree_oid = repo_a.treebuilder(None).unwrap().write().unwrap(); - let tree = repo_a.find_tree(tree_oid).unwrap(); - let init_oid = repo_a - .commit(Some("HEAD"), &sig_a, &sig_a, "initial", &tree, &[]) - .unwrap(); - - // Push initial commit to bare so repo B can work - repo_a - .reference("refs/remotes/origin/main", init_oid, true, "init") - .unwrap(); - - // Clone into repo B - let repo_b = git2::Repository::init(repo_b_dir.path()).unwrap(); - { - let mut config = repo_b.config().unwrap(); - config.set_str("user.email", "bob@example.com").unwrap(); - config.set_str("user.name", "Bob").unwrap(); - } - repo_b - .remote("origin", bare_dir.path().to_str().unwrap()) - .unwrap(); - // Give repo B the same initial commit - let sig_b = git2::Signature::now("Bob", "bob@example.com").unwrap(); - let tree_oid_b = repo_b.treebuilder(None).unwrap().write().unwrap(); - let tree_b = repo_b.find_tree(tree_oid_b).unwrap(); - repo_b - .commit(Some("HEAD"), &sig_b, &sig_b, "initial", &tree_b, &[]) - .unwrap(); - - // === Step 1: User A sets metadata and serializes === - gmeta(repo_a_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - "alice@example.com", - ]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "license", - "apache", - ]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // "Push": copy refs/meta/local from A to bare - let a_local_ref = repo_a.find_reference("refs/meta/local/main").unwrap(); - let a_local_oid = a_local_ref.peel_to_commit().unwrap().id(); - copy_meta_objects(&repo_a, &bare_dir); - let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); - bare_repo - .reference("refs/meta/local/main", a_local_oid, true, "push from A") - .unwrap(); - - // === Step 2: User B pulls and materializes (no new data) === - // "Fetch": copy meta objects from bare to B, set refs/meta/origin - copy_meta_objects_from(&bare_dir, &repo_b); - repo_b - .reference("refs/meta/origin", a_local_oid, true, "fetch from bare") - .unwrap(); - - gmeta(repo_b_dir.path()) - .args(["materialize"]) - .assert() - .success(); - - // B serializes (just the materialize merge commit, no new data) - gmeta(repo_b_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // "Push" B's local ref back to bare - let b_local_ref = repo_b.find_reference("refs/meta/local/main").unwrap(); - let b_local_oid = b_local_ref.peel_to_commit().unwrap().id(); - copy_meta_objects(&repo_b, &bare_dir); - let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); - bare_repo - .reference("refs/meta/local/main", b_local_oid, true, "push from B") - .unwrap(); - - // === Step 3: User A pulls B's ref, overwrites a value locally, serializes === - // "Fetch": copy objects from bare to A, update refs/meta/origin - copy_meta_objects_from(&bare_dir, &repo_a); - let bare_repo = git2::Repository::open_bare(bare_dir.path()).unwrap(); - let bare_local = bare_repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - repo_a - .reference("refs/meta/origin", bare_local, true, "fetch from bare") - .unwrap(); - - // A overwrites testing:user locally - gmeta(repo_a_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - "tom@example.com", - ]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // === Step 4: User A materializes — local change must survive === - gmeta(repo_a_dir.path()) - .args(["materialize"]) - .assert() - .success(); - - // Verify: testing:user should be tom (the local change), NOT alice (stale remote) - gmeta(repo_a_dir.path()) - .args(["get", "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp"]) - .assert() - .success() - .stdout(predicate::str::contains("testing:user")) - .stdout(predicate::str::contains("tom@example.com")) - .stdout(predicate::str::contains("alice@example.com").not()); - - // license should still be there (unchanged on both sides) - gmeta(repo_a_dir.path()) - .args([ - "get", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "license", - ]) - .assert() - .success() - .stdout(predicate::str::contains("apache")); -} - -/// Both User A and User B modify the same key. The one with the later -/// commit timestamp should win in the three-way merge. -/// -/// 1. User A sets testing:user=alice, serializes, pushes -/// 2. User B pulls, materializes, changes testing:user=bob, serializes, pushes -/// 3. User A changes testing:user=tom, serializes (AFTER B), materializes -/// → A's value wins because A serialized later -/// 4. Repeat but have A serialize BEFORE B pushes, then materialize -/// → B's value wins because B serialized later -#[test] -fn test_materialize_both_sides_modified_later_timestamp_wins() { - // === Setup: two repos sharing via bare intermediary === - let bare_dir = TempDir::new().unwrap(); - let repo_a_dir = TempDir::new().unwrap(); - let repo_b_dir = TempDir::new().unwrap(); - - git2::Repository::init_bare(bare_dir.path()).unwrap(); - - // Init repo A - let repo_a = git2::Repository::init(repo_a_dir.path()).unwrap(); - { - let mut config = repo_a.config().unwrap(); - config.set_str("user.email", "alice@example.com").unwrap(); - config.set_str("user.name", "Alice").unwrap(); - } - repo_a - .remote("origin", bare_dir.path().to_str().unwrap()) - .unwrap(); - let sig_a = git2::Signature::now("Alice", "alice@example.com").unwrap(); - let tree_oid = repo_a.treebuilder(None).unwrap().write().unwrap(); - let tree = repo_a.find_tree(tree_oid).unwrap(); - repo_a - .commit(Some("HEAD"), &sig_a, &sig_a, "initial", &tree, &[]) - .unwrap(); - - // Init repo B - let repo_b = git2::Repository::init(repo_b_dir.path()).unwrap(); - { - let mut config = repo_b.config().unwrap(); - config.set_str("user.email", "bob@example.com").unwrap(); - config.set_str("user.name", "Bob").unwrap(); - } - repo_b - .remote("origin", bare_dir.path().to_str().unwrap()) - .unwrap(); - let sig_b = git2::Signature::now("Bob", "bob@example.com").unwrap(); - let tree_oid_b = repo_b.treebuilder(None).unwrap().write().unwrap(); - let tree_b = repo_b.find_tree(tree_oid_b).unwrap(); - repo_b - .commit(Some("HEAD"), &sig_b, &sig_b, "initial", &tree_b, &[]) - .unwrap(); - - // === Step 1: User A sets initial data and serializes === - gmeta(repo_a_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - "alice@example.com", - ]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // Push A → bare - let a_oid = repo_a - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - copy_meta_objects(&repo_a, &bare_dir); - git2::Repository::open_bare(bare_dir.path()) - .unwrap() - .reference("refs/meta/local/main", a_oid, true, "push A") - .unwrap(); - - // === Step 2: User B pulls, materializes, modifies, serializes === - // Fetch bare → B - copy_meta_objects_from(&bare_dir, &repo_b); - repo_b - .reference("refs/meta/origin", a_oid, true, "fetch") - .unwrap(); - - gmeta(repo_b_dir.path()) - .args(["materialize"]) - .assert() - .success(); - - // B changes the same key - gmeta(repo_b_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - "bob@example.com", - ]) - .assert() - .success(); - - gmeta(repo_b_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // Push B → bare - let b_oid = repo_b - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - copy_meta_objects(&repo_b, &bare_dir); - git2::Repository::open_bare(bare_dir.path()) - .unwrap() - .reference("refs/meta/local/main", b_oid, true, "push B") - .unwrap(); - - // === Step 3: User A modifies the same key AFTER B, serializes, then materializes === - // A changes the value (this serialize will have a later timestamp than B's) - gmeta(repo_a_dir.path()) - .args([ - "set", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - "tom@example.com", - ]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - // Fetch B's changes into A - copy_meta_objects_from(&bare_dir, &repo_a); - repo_a - .reference("refs/meta/origin", b_oid, true, "fetch B") - .unwrap(); - - // Materialize — both sides changed, A's commit is newer → A wins - gmeta(repo_a_dir.path()) - .args(["materialize"]) - .assert() - .success(); - - gmeta(repo_a_dir.path()) - .args([ - "get", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - ]) - .assert() - .success() - .stdout(predicate::str::contains("tom@example.com")); - - // === Now test the reverse: B materializes A's newer changes, B's commit is older === - // Fetch A's latest into B - let a_oid_new = repo_a - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - copy_meta_objects(&repo_a, &bare_dir); - git2::Repository::open_bare(bare_dir.path()) - .unwrap() - .reference("refs/meta/local/main", a_oid_new, true, "push A new") - .unwrap(); - - copy_meta_objects_from(&bare_dir, &repo_b); - repo_b - .reference("refs/meta/origin", a_oid_new, true, "fetch A new") - .unwrap(); - - // B materializes — A's commit is newer → A's value (tom) wins over B's (bob) - gmeta(repo_b_dir.path()) - .args(["materialize"]) - .assert() - .success(); - - gmeta(repo_b_dir.path()) - .args([ - "get", - "change-id:uzytqkxrnstmxlzmvwluqomoynnowolp", - "testing:user", - ]) - .assert() - .success() - .stdout(predicate::str::contains("tom@example.com")); -} - -#[test] -fn test_materialize_dry_run_does_not_mutate_sqlite_or_ref() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "v1"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let first_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - drop(repo); - - gmeta(dir.path()) - .args(["set", &target, "agent:model", "v2"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let second_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - repo.reference("refs/meta/origin", second_oid, true, "test remote") - .unwrap(); - repo.reference("refs/meta/local/main", first_oid, true, "rollback local") - .unwrap(); - drop(repo); - - // Local DB diverges from remote tree. - gmeta(dir.path()) - .args(["set", &target, "agent:model", "stale"]) - .assert() - .success(); - - gmeta(dir.path()) - .args(["materialize", "--dry-run"]) - .assert() - .success() - .stdout(predicate::str::contains("dry-run: strategy=fast-forward")) - .stdout(predicate::str::contains("agent:model")); - - // SQLite should not be updated by dry-run. - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::contains("stale")) - .stdout(predicate::str::contains("v2").not()); - - // Local metadata ref should not move in dry-run. - let repo = git2::Repository::open(dir.path()).unwrap(); - let local_after = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - assert_eq!(local_after, first_oid); -} - -#[test] -fn test_materialize_dry_run_reports_concurrent_add_conflict_resolution() { - let (dir, sha) = setup_repo(); - let target = commit_target(&sha); - - // Base snapshot without agent:model. - gmeta(dir.path()) - .args(["set", &target, "base:key", "base"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let base_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - drop(repo); - - // Remote branch adds agent:model=remote. - gmeta(dir.path()) - .args(["set", &target, "agent:model", "remote"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let remote_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - repo.reference("refs/meta/local/main", base_oid, true, "rollback to base") - .unwrap(); - drop(repo); - - // Local branch adds the same key with a different value. - gmeta(dir.path()) - .args(["set", &target, "agent:model", "local"]) - .assert() - .success(); - gmeta(dir.path()).args(["serialize"]).assert().success(); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let local_oid = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - repo.reference("refs/meta/origin", remote_oid, true, "set remote") - .unwrap(); - drop(repo); - - gmeta(dir.path()) - .args(["materialize", "--dry-run"]) - .assert() - .success() - .stdout(predicate::str::contains("dry-run: strategy=three-way")) - .stdout(predicate::str::contains("reason=concurrent-add")) - .stdout(predicate::str::contains("agent:model")); - - // Dry-run keeps local state unchanged. - gmeta(dir.path()) - .args(["get", &target, "agent:model"]) - .assert() - .success() - .stdout(predicate::str::contains("local")); - - let repo = git2::Repository::open(dir.path()).unwrap(); - let local_after = repo - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - assert_eq!(local_after, local_oid); -} - -#[test] -fn test_materialize_no_common_ancestor_uses_two_way_merge_remote_wins() { - let bare_dir = TempDir::new().unwrap(); - git2::Repository::init_bare(bare_dir.path()).unwrap(); - let (repo_a_dir, _sha_a) = setup_repo(); - let (repo_b_dir, _sha_b) = setup_repo(); - - // Local side (A) - gmeta(repo_a_dir.path()) - .args(["set", "project", "agent:model", "local"]) - .assert() - .success(); - gmeta(repo_a_dir.path()) - .args(["set", "project", "local:only", "keep-me"]) - .assert() - .success(); - gmeta(repo_a_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - let repo_a = git2::Repository::open(repo_a_dir.path()).unwrap(); - let a_oid = repo_a - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - - // Remote side (B), completely independent history - gmeta(repo_b_dir.path()) - .args(["set", "project", "agent:model", "remote"]) - .assert() - .success(); - gmeta(repo_b_dir.path()) - .args(["set", "project", "remote:only", "keep-too"]) - .assert() - .success(); - gmeta(repo_b_dir.path()) - .args(["serialize"]) - .assert() - .success(); - - let repo_b = git2::Repository::open(repo_b_dir.path()).unwrap(); - let b_oid = repo_b - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - - // Simulate fetch: B -> bare -> A - copy_meta_objects(&repo_b, &bare_dir); - git2::Repository::open_bare(bare_dir.path()) - .unwrap() - .reference("refs/meta/local/main", b_oid, true, "push B") - .unwrap(); - copy_meta_objects_from(&bare_dir, &repo_a); - repo_a - .reference("refs/meta/origin", b_oid, true, "fetch B into A") - .unwrap(); - - // No common ancestor should be identified in dry-run. - gmeta(repo_a_dir.path()) - .args(["materialize", "--dry-run"]) - .assert() - .success() - .stdout(predicate::str::contains("no common ancestor")) - .stdout(predicate::str::contains( - "strategy=two-way-no-common-ancestor", - )) - .stdout(predicate::str::contains( - "reason=no-common-ancestor-local-wins", - )) - .stdout(predicate::str::contains("agent:model")); - - // Dry-run should not move local ref. - let a_after_dry_run = repo_a - .find_reference("refs/meta/local/main") - .unwrap() - .peel_to_commit() - .unwrap() - .id(); - assert_eq!(a_after_dry_run, a_oid); - - // Real materialize applies two-way merge where local wins conflicts. - gmeta(repo_a_dir.path()) - .args(["materialize"]) - .assert() - .success() - .stdout(predicate::str::contains("two-way merge")); - - // Conflict key should come from local. - gmeta(repo_a_dir.path()) - .args(["get", "project", "agent:model"]) - .assert() - .success() - .stdout(predicate::str::contains("local")) - .stdout(predicate::str::contains("remote").not()); - - // Non-conflicting keys from both sides should be preserved. - gmeta(repo_a_dir.path()) - .args(["get", "project", "local:only"]) - .assert() - .success() - .stdout(predicate::str::contains("keep-me")); - gmeta(repo_a_dir.path()) - .args(["get", "project", "remote:only"]) - .assert() - .success() - .stdout(predicate::str::contains("keep-too")); -} - -// ── Remote / Push / Pull integration tests ───────────────────────────────── - -/// Create a bare repo that has a refs/meta/main with some metadata. -/// Returns the TempDir for the bare repo. -fn setup_bare_with_meta(ns: &str) -> TempDir { - let bare_dir = TempDir::new().unwrap(); - let bare = git2::Repository::init_bare(bare_dir.path()).unwrap(); - - // Build a tree with some metadata - let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); - let mut tb = bare.treebuilder(None).unwrap(); - - // Create a subtree: project/testing/__value - let blob_oid = bare.blob(b"\"hello\"").unwrap(); - let mut sub_tb = bare.treebuilder(None).unwrap(); - sub_tb.insert("__value", blob_oid, 0o100644).unwrap(); - let sub_tree_oid = sub_tb.write().unwrap(); - - let mut project_tb = bare.treebuilder(None).unwrap(); - project_tb - .insert("testing", sub_tree_oid, 0o040000) - .unwrap(); - let project_tree_oid = project_tb.write().unwrap(); - - tb.insert("project", project_tree_oid, 0o040000).unwrap(); - let tree_oid = tb.write().unwrap(); - let tree = bare.find_tree(tree_oid).unwrap(); - - let ref_name = format!("refs/{}/main", ns); - bare.commit(Some(&ref_name), &sig, &sig, "initial meta", &tree, &[]) - .unwrap(); - - bare_dir -} - -#[test] -fn test_remote_add_no_meta_refs() { - let (dir, _sha) = setup_repo(); - // Bare repo with no meta refs at all - let bare_dir = TempDir::new().unwrap(); - git2::Repository::init_bare(bare_dir.path()).unwrap(); - - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .failure() - .stderr(predicate::str::contains("no metadata refs found")); -} - -#[test] -fn test_remote_add_meta_refs_in_different_namespace() { - let (dir, _sha) = setup_repo(); - // Bare repo with refs/altmeta/main but not refs/meta/main - let bare_dir = setup_bare_with_meta("altmeta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .failure() - .stderr(predicate::str::contains("refs/altmeta/main")) - .stderr(predicate::str::contains("--namespace=altmeta")); -} - -#[test] -fn test_remote_add_with_namespace_override() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("altmeta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path, "--namespace=altmeta"]) - .assert() - .success() - .stdout(predicate::str::contains("Added meta remote")); - - // Verify config has the correct fetch refspec - let repo = git2::Repository::open(dir.path()).unwrap(); - let config = repo.config().unwrap(); - let fetch = config.get_string("remote.meta.fetch").unwrap(); - assert!( - fetch.contains("refs/altmeta/"), - "fetch refspec should use altmeta namespace, got: {}", - fetch - ); - let meta_ns = config.get_string("remote.meta.metanamespace").unwrap(); - assert_eq!(meta_ns, "altmeta"); -} - -#[test] -fn test_remote_add_shorthand_url_expansion() { - let (dir, _sha) = setup_repo(); - - // Shorthand "owner/repo" should expand to git@github.com:owner/repo.git. - // The command will succeed (warning on fetch failure) — verify the expanded URL - // appears in the output. - gmeta(dir.path()) - .args(["remote", "add", "nonexistent-user-xyz/nonexistent-repo-xyz"]) - .assert() - .success() - .stdout(predicate::str::contains( - "git@github.com:nonexistent-user-xyz/nonexistent-repo-xyz.git", - )); - - // Verify the config stored the expanded URL - let repo = git2::Repository::open(dir.path()).unwrap(); - let config = repo.config().unwrap(); - let url = config.get_string("remote.meta.url").unwrap(); - assert_eq!( - url, - "git@github.com:nonexistent-user-xyz/nonexistent-repo-xyz.git" - ); -} - -#[test] -fn test_remote_list_and_remove() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - // Add - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - - // List - gmeta(dir.path()) - .args(["remote", "list"]) - .assert() - .success() - .stdout(predicate::str::contains("meta\t")) - .stdout(predicate::str::contains(bare_path)); - - // Remove - gmeta(dir.path()) - .args(["remote", "remove", "meta"]) - .assert() - .success() - .stdout(predicate::str::contains("Removed meta remote")); - - // List again — empty - gmeta(dir.path()) - .args(["remote", "list"]) - .assert() - .success() - .stdout(predicate::str::contains("No metadata remotes configured")); -} - -#[test] -fn test_push_simple() { - let (dir, sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - // Add remote and pull existing data - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - // Set local metadata - let target = commit_target(&sha); - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - // Push - gmeta(dir.path()) - .args(["push"]) - .assert() - .success() - .stdout(predicate::str::contains("Pushed metadata to meta")); - - // Verify: the pushed ref on the bare repo has no merge commits - let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); - let commit = bare - .find_reference("refs/meta/main") - .unwrap() - .peel_to_commit() - .unwrap(); - assert_eq!( - commit.parent_count(), - 1, - "pushed commit should have exactly 1 parent (no merge commits)" - ); -} - -#[test] -fn test_push_up_to_date() { - let (dir, sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - let target = commit_target(&sha); - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - - // First push - gmeta(dir.path()).args(["push"]).assert().success(); - - // Second push — nothing changed - gmeta(dir.path()) - .args(["push"]) - .assert() - .success() - .stdout(predicate::str::contains("Everything up-to-date")); -} - -#[test] -fn test_push_commit_message_format() { - let (dir, sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - let target = commit_target(&sha); - gmeta(dir.path()) - .args(["set", &target, "agent:model", "claude-4.6"]) - .assert() - .success(); - gmeta(dir.path()) - .args(["set", &target, "agent:cost", "0.05"]) - .assert() - .success(); - gmeta(dir.path()).args(["push"]).assert().success(); - - // Check the commit message on the bare repo - let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); - let commit = bare - .find_reference("refs/meta/main") - .unwrap() - .peel_to_commit() - .unwrap(); - let msg = commit.message().unwrap(); - assert!( - msg.contains("gmeta: serialize"), - "commit message should start with 'gmeta: serialize', got: {}", - msg - ); - assert!( - msg.contains("agent:model"), - "commit message should contain changed key, got: {}", - msg - ); -} - -#[test] -fn test_push_conflict_produces_no_merge_commits() { - // Two clones push different metadata — second clone should auto-merge - // and the result should have no merge commits - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - let (dir_a, sha_a) = setup_repo(); - let (dir_b, sha_b) = setup_repo(); - - // Both add the same remote - gmeta(dir_a.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir_b.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - - // Both pull initial state - gmeta(dir_a.path()).args(["pull"]).assert().success(); - gmeta(dir_b.path()).args(["pull"]).assert().success(); - - // A sets and pushes - let target_a = commit_target(&sha_a); - gmeta(dir_a.path()) - .args(["set", &target_a, "from:a", "value-a"]) - .assert() - .success(); - gmeta(dir_a.path()).args(["push"]).assert().success(); - - // B sets and pushes (should conflict then auto-merge) - let target_b = commit_target(&sha_b); - gmeta(dir_b.path()) - .args(["set", &target_b, "from:b", "value-b"]) - .assert() - .success(); - gmeta(dir_b.path()).args(["push"]).assert().success(); - - // Walk the entire history on the bare repo — no merge commits allowed - let bare = git2::Repository::open_bare(bare_dir.path()).unwrap(); - let tip = bare - .find_reference("refs/meta/main") - .unwrap() - .peel_to_commit() - .unwrap(); - - let mut revwalk = bare.revwalk().unwrap(); - revwalk.push(tip.id()).unwrap(); - for oid in revwalk { - let oid = oid.unwrap(); - let commit = bare.find_commit(oid).unwrap(); - assert!( - commit.parent_count() <= 1, - "commit {} has {} parents — merge commits are not allowed in pushed history", - &commit.id().to_string()[..8], - commit.parent_count() - ); - } -} - -#[test] -fn test_pull_simple() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - - gmeta(dir.path()).args(["pull"]).assert().success(); - - // The bare repo had project/testing = "hello" — check it materialized - // (may have been materialized during remote add or pull) - gmeta(dir.path()) - .args(["get", "project", "testing"]) - .assert() - .success() - .stdout(predicate::str::contains("hello")); -} - -#[test] -fn test_pull_up_to_date() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - - // First pull - gmeta(dir.path()).args(["pull"]).assert().success(); - - // Second pull — nothing new - gmeta(dir.path()) - .args(["pull"]) - .assert() - .success() - .stdout(predicate::str::contains("Already up-to-date")); -} - -#[test] -fn test_pull_merges_with_local_data() { - let (dir, sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - // Set some local-only metadata before adding the remote - let target = commit_target(&sha); - gmeta(dir.path()) - .args(["set", &target, "local:key", "local-value"]) - .assert() - .success(); - - // Add remote and pull - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - // Should have both local and remote data - gmeta(dir.path()) - .args(["get", &target, "local:key"]) - .assert() - .success() - .stdout(predicate::str::contains("local-value")); - - gmeta(dir.path()) - .args(["get", "project", "testing"]) - .assert() - .success() - .stdout(predicate::str::contains("hello")); -} - -/// Build a bare repo with multiple gmeta serialize commits for promisor tests. -/// Returns the bare TempDir. The repo has 2 commits: -/// Commit 1 (older): project/old_key/__value = "old_value" -/// Commit 2 (tip): project/testing/__value = "hello" -/// Commit 1's message lists: A\tproject\told_key -/// Commit 2's message lists: A\tproject\ttesting -fn setup_bare_with_history() -> TempDir { - let bare_dir = TempDir::new().unwrap(); - let bare = git2::Repository::init_bare(bare_dir.path()).unwrap(); - let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); - - // --- Commit 1: project/old_key/__value = "old_value" --- - let blob1 = bare.blob(b"\"old_value\"").unwrap(); - let mut val_tb = bare.treebuilder(None).unwrap(); - val_tb.insert("__value", blob1, 0o100644).unwrap(); - let val_tree = val_tb.write().unwrap(); - - let mut proj_tb = bare.treebuilder(None).unwrap(); - proj_tb.insert("old_key", val_tree, 0o040000).unwrap(); - let proj_tree = proj_tb.write().unwrap(); - - let mut root_tb = bare.treebuilder(None).unwrap(); - root_tb.insert("project", proj_tree, 0o040000).unwrap(); - let root_tree_oid = root_tb.write().unwrap(); - let root_tree = bare.find_tree(root_tree_oid).unwrap(); - - let commit1_msg = "gmeta: serialize (1 changes)\n\nA\tproject\told_key"; - let commit1 = bare - .commit(None, &sig, &sig, commit1_msg, &root_tree, &[]) - .unwrap(); - let commit1_obj = bare.find_commit(commit1).unwrap(); - - // --- Commit 2 (tip): project/testing/__value = "hello" (old_key removed) --- - let blob2 = bare.blob(b"\"hello\"").unwrap(); - let mut val_tb2 = bare.treebuilder(None).unwrap(); - val_tb2.insert("__value", blob2, 0o100644).unwrap(); - let val_tree2 = val_tb2.write().unwrap(); - - let mut proj_tb2 = bare.treebuilder(None).unwrap(); - proj_tb2.insert("testing", val_tree2, 0o040000).unwrap(); - let proj_tree2 = proj_tb2.write().unwrap(); - - let mut root_tb2 = bare.treebuilder(None).unwrap(); - root_tb2.insert("project", proj_tree2, 0o040000).unwrap(); - let root_tree_oid2 = root_tb2.write().unwrap(); - let root_tree2 = bare.find_tree(root_tree_oid2).unwrap(); - - let commit2_msg = "gmeta: serialize (1 changes)\n\nA\tproject\ttesting"; - bare.commit( - Some("refs/meta/main"), - &sig, - &sig, - commit2_msg, - &root_tree2, - &[&commit1_obj], - ) - .unwrap(); - - bare_dir -} - -#[test] -fn test_pull_inserts_promisor_entries() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_history(); - let bare_path = bare_dir.path().to_str().unwrap(); - - // Add remote — this now fetches, hydrates, materializes, and indexes promisor entries - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success() - .stderr(predicate::str::contains("Indexed 1 keys from history")); - - // The tip key (testing) should be available immediately - gmeta(dir.path()) - .args(["get", "project", "testing"]) - .assert() - .success() - .stdout(predicate::str::contains("hello")); - - // The historical key (old_key) was pruned from the tip tree, so it can't be hydrated. - // Get should silently skip it (promised but not resolvable in tip). - // Verify it doesn't crash. - gmeta(dir.path()) - .args(["get", "project", "old_key"]) - .assert() - .success(); - - // Verify the promisor entry exists in the DB by checking that the key shows - // up in get with --json for the full project target (it will be filtered out - // since it can't be hydrated, but the tip key should still work) - gmeta(dir.path()) - .args(["get", "project", "--json"]) - .assert() - .success() - .stdout(predicate::str::contains("testing")) - .stdout(predicate::str::contains("hello")); -} - -/// Build a bare repo where a key exists in both history and tip tree, -/// but the tip commit message only lists the newer key. -/// Commit 1: A project old_key (tree has project/old_key/__value) -/// Commit 2: A project testing (tree has project/testing/__value AND project/old_key/__value) -fn setup_bare_with_history_retained() -> TempDir { - let bare_dir = TempDir::new().unwrap(); - let bare = git2::Repository::init_bare(bare_dir.path()).unwrap(); - let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); - - // --- Commit 1: project/old_key/__value = "old_value" --- - let blob1 = bare.blob(b"\"old_value\"").unwrap(); - let mut val_tb = bare.treebuilder(None).unwrap(); - val_tb.insert("__value", blob1, 0o100644).unwrap(); - let val_tree = val_tb.write().unwrap(); - - let mut proj_tb = bare.treebuilder(None).unwrap(); - proj_tb.insert("old_key", val_tree, 0o040000).unwrap(); - let proj_tree = proj_tb.write().unwrap(); - - let mut root_tb = bare.treebuilder(None).unwrap(); - root_tb.insert("project", proj_tree, 0o040000).unwrap(); - let root_tree_oid = root_tb.write().unwrap(); - let root_tree = bare.find_tree(root_tree_oid).unwrap(); - - let commit1_msg = "gmeta: serialize (1 changes)\n\nA\tproject\told_key"; - let commit1 = bare - .commit(None, &sig, &sig, commit1_msg, &root_tree, &[]) - .unwrap(); - let commit1_obj = bare.find_commit(commit1).unwrap(); - - // --- Commit 2 (tip): has both old_key and testing --- - let blob2 = bare.blob(b"\"hello\"").unwrap(); - let mut val_tb2 = bare.treebuilder(None).unwrap(); - val_tb2.insert("__value", blob2, 0o100644).unwrap(); - let val_tree2 = val_tb2.write().unwrap(); - - let mut proj_tb2 = bare.treebuilder(None).unwrap(); - proj_tb2.insert("testing", val_tree2, 0o040000).unwrap(); - proj_tb2.insert("old_key", val_tree, 0o040000).unwrap(); - let proj_tree2 = proj_tb2.write().unwrap(); - - let mut root_tb2 = bare.treebuilder(None).unwrap(); - root_tb2.insert("project", proj_tree2, 0o040000).unwrap(); - let root_tree_oid2 = root_tb2.write().unwrap(); - let root_tree2 = bare.find_tree(root_tree_oid2).unwrap(); - - // Only mention 'testing' in the tip commit — old_key was added in commit 1 - let commit2_msg = "gmeta: serialize (1 changes)\n\nA\tproject\ttesting"; - bare.commit( - Some("refs/meta/main"), - &sig, - &sig, - commit2_msg, - &root_tree2, - &[&commit1_obj], - ) - .unwrap(); - - bare_dir -} - -#[test] -fn test_promisor_hydration_from_tip_tree() { - // old_key is in both the history and the tip tree, but tip commit only - // mentions 'testing'. Materialize processes the full tip tree, so old_key - // gets materialized as a real entry (not promised). - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_history_retained(); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - // Both keys should be available — old_key was in the tip tree so - // materialize handled it directly (not as a promisor entry) - gmeta(dir.path()) - .args(["get", "project", "old_key"]) - .assert() - .success() - .stdout(predicate::str::contains("old_value")); - - gmeta(dir.path()) - .args(["get", "project", "testing"]) - .assert() - .success() - .stdout(predicate::str::contains("hello")); -} - -#[test] -fn test_promisor_entry_not_serialized() { - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_history(); - let bare_path = bare_dir.path().to_str().unwrap(); - - // Pull to get promisor entries - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()).args(["pull"]).assert().success(); - - // Serialize — should not include promised entries in the commit - gmeta(dir.path()).args(["serialize"]).assert().success(); - - // Verify the local ref tree doesn't contain old_key (it was promised, not materialized) - let repo = git2::Repository::open(dir.path()).unwrap(); - let local_ref = repo.find_reference("refs/meta/local/main").unwrap(); - let commit = local_ref.peel_to_commit().unwrap(); - let tree = commit.tree().unwrap(); - - // The tree should have project/testing but not project/old_key - let project_entry = tree.get_name("project").unwrap(); - let project_tree = repo.find_tree(project_entry.id()).unwrap(); - - assert!( - project_tree.get_name("testing").is_some(), - "tip key 'testing' should be in serialized tree" - ); - assert!( - project_tree.get_name("old_key").is_none(), - "promised key 'old_key' should NOT be in serialized tree" - ); -} - -#[test] -fn test_pull_tip_only_no_promisor_entries() { - // A single-commit remote should produce no promisor entries - let (dir, _sha) = setup_repo(); - let bare_dir = setup_bare_with_meta("meta"); - let bare_path = bare_dir.path().to_str().unwrap(); - - gmeta(dir.path()) - .args(["remote", "add", bare_path]) - .assert() - .success(); - gmeta(dir.path()) - .args(["pull"]) - .assert() - .success() - // Should NOT contain "Indexed" since there are no non-tip commits to parse - .stderr(predicate::str::contains("Indexed").not()); -} - -/// Copy all git objects from src repo into a bare repo (simulates push). -fn copy_meta_objects(src: &git2::Repository, bare_dir: &TempDir) { - let src_objects = src.path().join("objects"); - let dst_objects = bare_dir.path().join("objects"); - copy_dir_contents(&src_objects, &dst_objects); -} - -/// Copy all git objects from a bare repo into dst repo (simulates fetch). -fn copy_meta_objects_from(bare_dir: &TempDir, dst: &git2::Repository) { - let src_objects = bare_dir.path().join("objects"); - let dst_objects = dst.path().join("objects"); - copy_dir_contents(&src_objects, &dst_objects); -} - -/// Recursively copy directory contents (for loose objects + pack files). -fn copy_dir_contents(src: &std::path::Path, dst: &std::path::Path) { - if !src.exists() { - return; - } - for entry in std::fs::read_dir(src).unwrap() { - let entry = entry.unwrap(); - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { - std::fs::create_dir_all(&dst_path).ok(); - copy_dir_contents(&src_path, &dst_path); - } else { - std::fs::copy(&src_path, &dst_path).ok(); - } - } -}