From 3f4eb6b17003c7d7124da2e59a7d29e53a19c15f Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 15:38:07 -0400 Subject: [PATCH 01/18] feat(review): scaffold git-workon-review lib+bin crate --- Cargo.lock | 1272 ++++++++++++++++++- Cargo.toml | 6 +- git-workon-review/Cargo.toml | 53 + git-workon-review/README.md | 7 + git-workon-review/src/error.rs | 14 + git-workon-review/src/lib.rs | 11 + git-workon-review/src/main.rs | 18 + git-workon-review/tests/cli.rs | 19 + git-workon-review/tests/treesitter_smoke.rs | 43 + 9 files changed, 1411 insertions(+), 32 deletions(-) create mode 100644 git-workon-review/Cargo.toml create mode 100644 git-workon-review/README.md create mode 100644 git-workon-review/src/error.rs create mode 100644 git-workon-review/src/lib.rs create mode 100644 git-workon-review/src/main.rs create mode 100644 git-workon-review/tests/cli.rs create mode 100644 git-workon-review/tests/treesitter_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 45a86a13..be644ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -76,6 +82,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "assert_cmd" version = "2.2.2" @@ -107,6 +128,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "auth-git2" version = "0.6.0" @@ -148,6 +178,27 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -156,9 +207,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bstr" @@ -177,6 +237,27 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -191,9 +272,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "clap" @@ -276,6 +363,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "conpty" version = "0.5.1" @@ -298,6 +399,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -323,6 +448,121 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -342,6 +582,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -374,6 +624,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -403,6 +668,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -413,6 +684,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "expectrl" version = "0.9.0" @@ -420,7 +700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e0df3044b2257277f573d1e40912ebd4d5891f7588640e3125cbbbb24ff7be3" dependencies = [ "conpty", - "nix", + "nix 0.26.4", "ptyprocess", "regex", ] @@ -437,18 +717,57 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "float-cmp" version = "0.10.0" @@ -458,6 +777,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -517,6 +842,16 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.10" @@ -618,7 +953,25 @@ dependencies = [ "rusqlite", "serde_json", "serial_test", - "thiserror", + "thiserror 2.0.19", +] + +[[package]] +name = "git-workon-review" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "git-workon-fixture", + "git-workon-lib", + "git2", + "miette", + "predicates", + "ratatui", + "thiserror 2.0.19", + "tree-sitter", + "tree-sitter-highlight", + "tree-sitter-rust", ] [[package]] @@ -627,7 +980,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -661,7 +1014,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -672,6 +1025,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -681,6 +1036,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -699,6 +1056,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "icu_collections" version = "2.0.0" @@ -785,6 +1148,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -822,6 +1191,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indicatif" version = "0.18.5" @@ -835,6 +1214,28 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -856,6 +1257,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -906,6 +1316,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -926,6 +1359,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -972,6 +1411,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -984,6 +1432,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -999,12 +1453,37 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.7.1" @@ -1014,6 +1493,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miette" version = "7.6.0" @@ -1044,6 +1532,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1053,6 +1547,18 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.26.4" @@ -1062,16 +1568,56 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.7.1", "pin-utils", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -1081,6 +1627,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.1" @@ -1136,12 +1691,45 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "owo-colors" version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1177,6 +1765,100 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1219,6 +1901,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -1264,29 +1952,145 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "101be273c0b1680d7056afddbaa88f02b6e9f2dc161165c30bee9914b6025a79" dependencies = [ - "nix", + "nix 0.26.4", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", ] [[package]] -name = "quote" -version = "1.0.45" +name = "ratatui-termina" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" dependencies = [ - "proc-macro2", + "instability", + "ratatui-core", + "termina", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "ratatui-termwiz" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "ratatui-widgets" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] [[package]] name = "redox_syscall" @@ -1294,7 +2098,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -1305,7 +2109,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.10", "libredox", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1350,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1359,7 +2163,7 @@ version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1374,13 +2178,22 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1414,11 +2227,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.171" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] name = "serde_core" @@ -1442,14 +2265,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.144" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56177480b00303e689183f110b4e727bb4211d692c62d4fcd16d02be93077d40" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", - "ryu", + "serde", "serde_core", + "zmij", ] [[package]] @@ -1477,6 +2302,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -1489,6 +2325,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1519,12 +2392,45 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -1546,6 +2452,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1592,6 +2509,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminal-prompt" version = "0.2.3" @@ -1612,12 +2542,75 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -1628,13 +2621,33 @@ dependencies = [ "unicode-width 0.1.11", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1658,6 +2671,27 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.1" @@ -1668,6 +2702,60 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7a0c48d503cf4e0a57a2453424eaef2fce4b4269f13e3579e52f0d0c9e5cc8" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror 2.0.19", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -1686,6 +2774,23 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-width" version = "0.1.11" @@ -1727,12 +2832,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.0" @@ -1822,6 +2954,78 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2169,3 +3373,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 654447ae..77756137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" default-members = ["git-workon"] -members = ["git-workon", "git-workon-lib", "git-workon-fixture"] +members = ["git-workon", "git-workon-lib", "git-workon-fixture", "git-workon-review"] [workspace.package] authors = ["Eric Eldredge "] @@ -45,10 +45,14 @@ supports-color = "3" expectrl = "0.9" fuzzy-matcher = "0.3" pathdiff = "0.2.3" +ratatui = "0.30" rusqlite = { version = "0.40", features = ["bundled"] } serde_json = "1.0" serial_test = "3" thiserror = "2.0.18" +tree-sitter = "0.26" +tree-sitter-highlight = "0.26" +tree-sitter-rust = "0.24" unicode-width = "0.2.2" # The profile that 'dist' will build with diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml new file mode 100644 index 00000000..2f9c731d --- /dev/null +++ b/git-workon-review/Cargo.toml @@ -0,0 +1,53 @@ +[package] +authors.workspace = true +categories = ["command-line-utilities", "development-tools"] +description = "TUI for reviewing changesets" +edition.workspace = true +homepage.workspace = true +keywords = ["cli", "git", "review", "tui", "workon"] +license.workspace = true +name = "git-workon-review" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version = "0.1.0" +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", +] +# Not yet published to crates.io: flip to publish this crate at M3 (per RFC). +# At that point: remove this line, add `[[package]] name = "git-workon-review"` +# to release-plz.toml (no version_group — versioned independently of the CLI), +# and decide the `dist = false` posture below (the homebrew patch step in +# .github/workflows/release.yml stamps man/completions into every +# Formula/*.rb and must be reworked before this binary can be distributed). +publish = false + +[lib] +name = "workon_review" + +[features] +vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-openssl"] + +[dependencies] +clap.workspace = true +git-workon-lib.workspace = true +git2.workspace = true +miette.workspace = true +ratatui.workspace = true +thiserror.workspace = true + +[package.metadata.dist] +# Redundant with publish = false today; load-bearing at the M3 flip so +# cargo-dist doesn't silently start shipping the (still undesigned) binary. +dist = false + +[dev-dependencies] +assert_cmd.workspace = true +git-workon-fixture.workspace = true +predicates.workspace = true +tree-sitter.workspace = true +tree-sitter-highlight.workspace = true +tree-sitter-rust.workspace = true diff --git a/git-workon-review/README.md b/git-workon-review/README.md new file mode 100644 index 00000000..77355d32 --- /dev/null +++ b/git-workon-review/README.md @@ -0,0 +1,7 @@ +# git-workon-review + +A standalone TUI for reviewing changesets — any branch/ref/range/stack. + +This crate is scaffolding (M0): the binary builds and prints help, but no +review functionality exists yet. See `docs/rfc/workon-review.md` in the +workspace root for the full design. diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs new file mode 100644 index 00000000..e61259ec --- /dev/null +++ b/git-workon-review/src/error.rs @@ -0,0 +1,14 @@ +use miette::Diagnostic; +use thiserror::Error; + +/// Result type alias using ReviewError +pub type Result = std::result::Result; + +/// Main error type for the review library +#[derive(Error, Diagnostic, Debug)] +pub enum ReviewError { + /// Git operation failed + #[error(transparent)] + #[diagnostic(code(workon::review::git_error))] + Git(#[from] git2::Error), +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs new file mode 100644 index 00000000..f76815ae --- /dev/null +++ b/git-workon-review/src/lib.rs @@ -0,0 +1,11 @@ +//! Core library for `git-workon-review`, a TUI for reviewing changesets. +//! +//! This library (lib target `workon_review`) will hold the review domain: +//! diff parsing, word-diff, line-precise staging, and changeset views. See +//! `docs/rfc/workon-review.md` in the workspace root for the full design. +//! +//! ## Status +//! +//! M0 scaffolding only — no review logic exists yet. + +pub mod error; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs new file mode 100644 index 00000000..d7a38318 --- /dev/null +++ b/git-workon-review/src/main.rs @@ -0,0 +1,18 @@ +use clap::Parser; + +/// A TUI for reviewing changesets +#[derive(Debug, Parser)] +#[clap( + about, + author, + bin_name = env!("CARGO_PKG_NAME"), + version, + arg_required_else_help = true +)] +struct Cli {} + +fn main() -> miette::Result<()> { + Cli::parse(); + + Ok(()) +} diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/cli.rs new file mode 100644 index 00000000..c621cf2d --- /dev/null +++ b/git-workon-review/tests/cli.rs @@ -0,0 +1,19 @@ +use assert_cmd::cargo_bin_cmd; +use predicates::prelude::*; + +#[test] +fn no_args_shows_usage_and_fails() { + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Usage")); +} + +#[test] +fn help_shows_usage_and_succeeds() { + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("git-workon-review")); +} diff --git a/git-workon-review/tests/treesitter_smoke.rs b/git-workon-review/tests/treesitter_smoke.rs new file mode 100644 index 00000000..e8acfb2b --- /dev/null +++ b/git-workon-review/tests/treesitter_smoke.rs @@ -0,0 +1,43 @@ +//! Proves the C-compilation path for tree-sitter grammars works end-to-end +//! in CI on all platforms: parse a snippet and run one highlight pass. + +use tree_sitter::Parser; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; + +#[test] +fn parses_rust_snippet_without_errors() { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .expect("rust grammar loads"); + + let tree = parser.parse("fn main() {}", None).expect("parses"); + assert!(!tree.root_node().has_error()); +} + +#[test] +fn highlight_pass_emits_at_least_one_highlight_start() { + let mut config = HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("highlight configuration builds"); + config.configure(&["keyword", "function"]); + + let mut highlighter = Highlighter::new(); + let events = highlighter + .highlight(&config, b"fn main() {}", None, |_| None) + .expect("highlighting succeeds"); + + let saw_highlight_start = events + .filter_map(|event| event.ok()) + .any(|event| matches!(event, HighlightEvent::HighlightStart(_))); + + assert!( + saw_highlight_start, + "expected at least one HighlightStart event" + ); +} From 742eb9fb71d8fe47d7ad192d8b161f3a455381c0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 15:38:11 -0400 Subject: [PATCH 02/18] docs: record review crate workspace placement in ADR-033 --- CLAUDE.md | 8 ++- docs/INDEX.md | 6 ++ .../033-review-crate-workspace-placement.md | 30 +++++++++ docs/rfc/workon-review.md | 62 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 docs/adr/033-review-crate-workspace-placement.md create mode 100644 docs/rfc/workon-review.md diff --git a/CLAUDE.md b/CLAUDE.md index 56bc8ab4..46ed405b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Workspace Structure -This is a Cargo workspace with three crates: +This is a Cargo workspace with four crates: - **git-workon** (git-workon/): The CLI binary that provides the user-facing commands - **git-workon-lib** (git-workon-lib/): Core library (published as `workon`) containing the git worktree manipulation logic - **git-workon-fixture** (git-workon-fixture/): Testing utilities that provide fixture builders and custom predicates for git repository tests +- **git-workon-review** (git-workon-review/): Lib+bin crate for the review TUI domain — diff parsing, staging, changeset views; the binary is the TUI ## File Location Quick Reference @@ -32,6 +33,9 @@ This is a Cargo workspace with three crates: - Add integration tests → `git-workon-lib/tests/` or `git-workon/tests/` - Find workon root logic → `git-workon-lib/src/workon_root.rs` - Smart routing logic → `git-workon/src/main.rs` (lines 20-38) +- Add review domain logic → `git-workon-review/src/` +- Add review CLI entry → `git-workon-review/src/main.rs` +- Add review error types → `git-workon-review/src/error.rs` (ADR-008 pattern: concrete enums with `#[derive(Error, Diagnostic)]`) ## Key Architecture Concepts @@ -73,7 +77,7 @@ Inline test/clippy runs go through `cargo-gate test`/`clippy` (a raw `cargo test **Valid types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` -**Scopes** (optional): `cli`, `lib`, `fixture`, `config`, `worktree`, `hooks`, `copy`, `pr`, `completions`, `build`, `release` +**Scopes** (optional): `cli`, `lib`, `fixture`, `review`, `config`, `worktree`, `hooks`, `copy`, `pr`, `completions`, `build`, `release` **Breaking changes**: append `!` — e.g. `feat(cli)!: change output format` diff --git a/docs/INDEX.md b/docs/INDEX.md index d6a5da06..ae130506 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -58,6 +58,12 @@ Maps subsystems and topics to relevant documentation files and source paths. Use - `docs/adr/009-pr-workflow-gh-cli.md` - Key source: `git-workon-lib/src/pr.rs`, `git-workon/src/cmd/new.rs` +### review / workon-review + +- `docs/rfc/workon-review.md` +- `docs/adr/033-review-crate-workspace-placement.md` +- Key source: `git-workon-review/src/` + ## Cross-cutting Concerns ### errors / error-handling / miette diff --git a/docs/adr/033-review-crate-workspace-placement.md b/docs/adr/033-review-crate-workspace-placement.md new file mode 100644 index 00000000..4986768e --- /dev/null +++ b/docs/adr/033-review-crate-workspace-placement.md @@ -0,0 +1,30 @@ +# 033 — Review Crate Workspace Placement + +## Context + +The RFC (`docs/rfc/workon-review.md`) defines `git-workon-review`, a standalone TUI for reviewing changesets, as a fourth sibling crate in this workspace (ADR-003). The existing release pipeline (ADR-020) auto-publishes any new publishable crate on the next `main` push — release-plz's `release` job publishes any registry-unmatched package without waiting for a release PR — and cargo-dist auto-includes any publishable bin crate as a distributed App. Both behaviors are wrong for a crate that starts as an empty scaffold. + +## Decision + +Add `git-workon-review` as a sibling crate: lib target `workon_review`, bin target `git-workon-review`. + +- **`publish = false`** in the crate's `Cargo.toml` is the single knob that keeps it out of both release-plz and cargo-dist, following the `git-workon-fixture` precedent (proven across ~20 releases). +- **`[package.metadata.dist] dist = false`** is set explicitly as well. It is redundant today (`publish = false` already excludes the crate) but is the tripwire for the M3 flip: removing `publish = false` alone, without also deciding this field, would silently make cargo-dist ship the binary. +- **Independent versioning**: the crate does not join `version_group = "main"` in `release-plz.toml`. Joining would lockstep its version to the CLI's and cross-bump the CLI on every review-crate change. +- **Workspace `rust-version` bumped to `1.88`** (ratatui 0.30's floor). `clap` 4.6 already required 1.85, so the workspace's previous `1.68.2` declaration was already unsatisfiable in practice; only the fixture crate had ever inherited the field. `rust-version.workspace = true` is added to all four crates so the field is real everywhere. + +## Consequences + +- The crate builds and tests in CI from the start (M0) without appearing in crates.io or in any cargo-dist release artifact. +- The M3 flip (when the review binary is ready to distribute) requires: + 1. Remove `publish = false` from `git-workon-review/Cargo.toml`. + 2. Add `[[package]] name = "git-workon-review"` to `release-plz.toml`, with **no** `version_group` — independent versioning is intentional, not an oversight to fix later. + 3. Keep `dist = false` until binary distribution is designed. The homebrew publish job in `.github/workflows/release.yml` patches **every** `Formula/*.rb` with `git-workon`'s man page and completions install lines; it must be reworked before a second binary can safely flow through it. `release-plz.yml`'s `dist` dispatch step is also hardcoded to fire only for `package_name == "git-workon"` and needs updating too. +- Until the M3 flip, the crate's version in its own `Cargo.toml` is cosmetic — release-plz never touches it. + +## References + +- `docs/rfc/workon-review.md` — RFC defining the review crate +- [ADR-003](003-three-crate-workspace.md) — workspace structure this crate joins +- [ADR-019](019-ci-quality-gates.md) — CI gates the new crate is subject to +- [ADR-020](020-two-tool-release-pipeline.md) — release pipeline whose auto-publish/auto-dist behavior this ADR opts the crate out of diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md new file mode 100644 index 00000000..bc68e3e9 --- /dev/null +++ b/docs/rfc/workon-review.md @@ -0,0 +1,62 @@ +# git-workon-review: Scaffolding Plan + +Status: **accepted** — decisions below are settled (2026-07-05 design sessions); this doc is the execution plan for scaffolding. +Prior art in this repo: [stacked-diffs.md](./stacked-diffs.md), [agent-integration.md](./agent-integration.md). + +## What it is + +`git-workon-review` is a standalone TUI for reviewing changesets — any branch/ref/range/stack (including reviewing what a coding agent did before it lands). It renders side-by-side diffs with word-level emphasis and tree-sitter syntax highlighting, supports line-precise staging as the accept/reject verb, navigates graphite/git stacks changeset-by-changeset, and can feed review comments back to a coding agent via MCP. It embeds cleanly in an editor terminal (lazygit-style) and runs standalone. + +It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/review/`, ~6k lines Lua, feature-complete through line-precise staging). The prototype is **frozen** (bug fixes only); new features land here first. A renderer spike (`~/Code/review-tui-spike`) validated the ratatui approach — port its modules, don't depend on it. + +## Decision log + +| Decision | Outcome | +|---|---| +| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. | +| Home | This workspace, as sibling crate `git-workon-review`. | +| Crate layout | ONE crate, lib+bin targets. lib = review domain (diff parse, word-diff, staging, changeset views); bin = TUI + `mcp` subcommand. No separate core crate until a second consumer exists. | +| Name | Package == binary == `git-workon-review`. `git workon-review` works via git's native `git-*` dispatch. (`git-review` is squatted on crates.io + Gerrit-loaded; `docket` too docker-adjacent; bare `review` superseded by suite framing; `signoff` was the free runner-up.) | +| `git-workon review` dispatch | `git-workon` adds cargo-style external-subcommand dispatch: unknown subcommand → exec `git-workon-` on PATH, args passed through. | +| NO `workon` binary | Deliberate: Python virtualenvwrapper keeps the `workon` name. Do not re-propose. | +| Git substrate | git2 throughout, aligned with git-workon-lib. Consequence: the prototype's patch/staging semantics were validated against git CLI — must re-verify against libgit2 (see Trap corpus). Escape hatch if libgit2 apply diverges: shell out to `git apply` for writes only. | +| Stack capabilities | All three land in **git-workon-lib** (not the review crate): (1) needs-restack via `parentBranchRevision` (present in both metadata formats, currently unread), (2) git-inference StackModel for metadata-less repos (in-flight semantics: upstream..HEAD per-commit changesets), (3) changeset assembly (base..head pairs + uncommitted layer + focus). Lib stays diff-free. | +| Lib hygiene | Remove unused `dialoguer`/`env_logger` from git-workon-lib deps; optionally feature-gate the network stack (clone/fetch/auth-git2 behind default-on `network` feature). | +| Fixture | `git-workon-fixture` is the test substrate for both crates. Extend it: SQLite-format graphite metadata mode (the sqlite read path is currently fixture-untested — builder only writes legacy refs blobs) and index-state builders (staged/unstaged/untracked combos). | +| Highlighting | tree-sitter (tree-sitter-highlight), syntect as long-tail fallback. Measured: ts ~0.01ms/line vs syntect ~0.19ms/line, and better output. Grammar set + gotchas are in the spike. | +| View model | Full parity with the prototype's four zoom states (split/combined/unstaged/staged + attributed rendering). If v1 must shrink, cut zoom states — never the comments loop. | +| v1 sources | uncommitted, stack, ref/range. PR deferred (git-workon-lib's `pr.rs` covers much of it later). | +| Comments | MCP: on-disk comment store (`.review/` JSON or sqlite) + `git-workon-review mcp` stdio subcommand serving get/resolve tools; TUI watches the store. Degrades to a plain file convention for non-MCP harnesses. | +| Edit flow | Embedded: `nvim --server $NVIM --remote + `. Standalone: `$EDITOR`. File watcher refreshes on save. | +| Completions | Full clap_complete (unstable-dynamic, already a workspace dep) on the direct binary. Work item: git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Git-level shims: on demand only. | +| Study first | `jjr` crate (agent jj-stack review surface), `triage-tui`, `wb300` — adjacent tools found during naming research. | + +## Reference material + +- **Prototype** (`~/.config/nvim/lua/app/review/`): the behavioral spec. Key modules: `diff/parser.lua` (hunk parse + patch synthesis — the crown jewels), `staging.lua` (FIFO queue semantics), `docket.lua` (`_gate` zoom matrix, window topology), `source/stack.lua` + `source/graph/` (graphite walk, git fallback, in-flight semantics), `ui/diff.lua` (rendering + attribution), colocated `*_spec.lua` files. E2E harness: `nvim/tests/review/`. +- **Spike** (`~/Code/review-tui-spike`): port `align.rs` (SBS row pairing + parity invariant), `wordiff.rs` (similar-based spans), `highlight_ts.rs` (grammar set, theme, per-line span splitting; JS exports `HIGHLIGHT_QUERY` singular + separate JSX query; TS/TSX queries concatenate TS-specific-first), `ui.rs` (viewport-sliced rendering), `diff.rs` (parser fallback to `diff --git` header for binary files). Bench mode worth keeping. + +## Trap corpus (port as tests FIRST — none of this is guessable) + +Hard-won semantics from the prototype, all of which caused real bugs. Each becomes a test before its feature is implemented: + +1. **Patch direction rules**: synthesizing a partial patch (line-precise staging) has direction-dependent drop rules. Forward apply (stage): dropped adds omitted, dropped dels → context. Reverse apply (unstage `--cached --reverse`, discard `--reverse`): dropped adds → context, dropped dels omitted — git rejects any partial selection otherwise. Round-trip test both directions + a tripwire asserting forward rules do NOT reverse-apply. +2. **No-newline EOF corruption (silent!)**: a dropped del converted to context carrying the `\ No newline at end of file` marker, followed by a kept add, is ACCEPTED by git apply (exit 0) which concatenates the add onto the no-newline line — corrupt blob, no error. Fix: splice into del+re-add form when kept lines follow. Assert the exact blob bytes. +3. **Whole-file ops for A/D/U statuses**: hunk-level patches can't express creations/deletions (untracked hunk-stage errors; deleted-file hunk-stage stages an EMPTY BLOB). Fall back to file-level ops; line-selection on these REFUSES with a notify. +4. **Staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). +5. **Refresh generation/livelock**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. +6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). + +## Milestones + +- **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-033](../adr/033-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. +- **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. +- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. +- **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. +- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. +- **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). +- **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. + +## Orchestration notes + +Main-thread implementation; subagents only for explore/plan/code-review fan-out. Model tiers: design-heavy work on the strongest model; well-understood ports (M2 corpus, M3 spike port) delegate well to mid-tier; mechanical work (fixture builders, CI wiring) to the fast tier. Review each milestone (`/code-review`) before landing; run the full workspace test suite per milestone, not per commit. From dac8172564223ee53810ed9f84839935c19e0b39 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 17:48:49 -0400 Subject: [PATCH 03/18] docs(review): add stale-metadata-head trap to RFC corpus --- docs/rfc/workon-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index bc68e3e9..784ecf56 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -46,6 +46,7 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom 4. **Staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). 5. **Refresh generation/livelock**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. 6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). +7. **Metadata revisions are snapshots, not refs** (found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. ## Milestones From bf2690c0f3e5c32bd106537c432b63e24c4d119a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:08:09 -0400 Subject: [PATCH 04/18] feat(review): build diff model from git2 diffs --- git-workon-review/src/acquire.rs | 96 ++++++ git-workon-review/src/error.rs | 25 ++ git-workon-review/src/lib.rs | 11 +- git-workon-review/src/model.rs | 236 +++++++++++++++ git-workon-review/tests/diff_model.rs | 409 ++++++++++++++++++++++++++ 5 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 git-workon-review/src/acquire.rs create mode 100644 git-workon-review/src/model.rs create mode 100644 git-workon-review/tests/diff_model.rs diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs new file mode 100644 index 00000000..30512e97 --- /dev/null +++ b/git-workon-review/src/acquire.rs @@ -0,0 +1,96 @@ +//! Acquiring a [`DiffModel`] for a resolved rev pair or the live worktree, and routing a +//! [`workon::Changeset`] to the right one. +//! +//! Stays deliberately thin: [`workon::assemble_changesets`] already resolved *what* to diff +//! (a committed rev pair, or "uncommitted"); this module only knows *how* to turn that into +//! git2 diffs and then a [`DiffModel`]. + +use git2::{DiffOptions, Oid, Repository}; +use workon::{Changeset, ChangesetSource}; + +use crate::error::DiffError; +use crate::model::DiffModel; + +/// The two working-tree diffs a review session needs: the index against `HEAD` (staged), and +/// the working tree against the index (unstaged, including untracked content). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeDiffs { + pub staged: DiffModel, + pub unstaged: DiffModel, +} + +/// Diff `HEAD`'s tree against the index (staged) and the index against the working tree +/// (unstaged), for a [`ChangesetSource::Uncommitted`] changeset. +/// +/// The unstaged side sets `include_untracked`/`recurse_untracked_dirs`/ +/// `show_untracked_content` so untracked files carry real content in the model (git2 gives +/// `Delta::Untracked` natively here — no `/dev/null` header synthesis needed). +pub fn diff_uncommitted(repo: &Repository) -> Result { + let head_tree = repo.head()?.peel_to_tree()?; + + let mut staged_opts = DiffOptions::new(); + staged_opts.context_lines(3); + let staged_diff = repo.diff_tree_to_index(Some(&head_tree), None, Some(&mut staged_opts))?; + let staged = DiffModel::from_git2(&staged_diff)?; + + let mut unstaged_opts = DiffOptions::new(); + unstaged_opts + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true) + .context_lines(3); + let unstaged_diff = repo.diff_index_to_workdir(None, Some(&mut unstaged_opts))?; + let unstaged = DiffModel::from_git2(&unstaged_diff)?; + + Ok(WorktreeDiffs { staged, unstaged }) +} + +/// Diff `base`'s tree against `head`'s tree, for a [`ChangesetSource::Committed`] changeset — +/// rename/copy detection runs via [`git2::Diff::find_similar`] so renamed files come back as +/// [`crate::model::FileStatus::Renamed`] instead of a delete+add pair. +pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result { + let base_tree = repo.find_commit(base)?.tree()?; + let head_tree = repo.find_commit(head)?.tree()?; + + let mut opts = DiffOptions::new(); + opts.context_lines(3); + let mut diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?; + diff.find_similar(None)?; + + DiffModel::from_git2(&diff) +} + +/// The diff for one [`Changeset`], shaped by its [`ChangesetSource`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangesetDiff { + Committed(DiffModel), + Uncommitted(WorktreeDiffs), +} + +/// Diff `cs`, routing on its [`ChangesetSource`]. +/// +/// A changeset carrying a resolved-but-unreadable rev pair (a bad or garbage `Oid` — e.g. +/// stale Graphite metadata pointing at a pruned commit) is a genuine failure, never an empty +/// [`DiffModel`]: any underlying git2 error is reported as +/// [`DiffError::ChangesetDiffFailed`]. +pub fn diff_changeset(repo: &Repository, cs: &Changeset) -> Result { + match cs.source { + ChangesetSource::Committed { base, head } => diff_committed(repo, base, head) + .map(ChangesetDiff::Committed) + .map_err(|err| changeset_diff_failed(&cs.name, err)), + ChangesetSource::Uncommitted => diff_uncommitted(repo) + .map(ChangesetDiff::Uncommitted) + .map_err(|err| changeset_diff_failed(&cs.name, err)), + } +} + +/// Fold a [`DiffError`] into [`DiffError::ChangesetDiffFailed`], attaching the changeset name. +fn changeset_diff_failed(name: &str, err: DiffError) -> DiffError { + match err { + DiffError::Git(source) => DiffError::ChangesetDiffFailed { + name: name.to_string(), + source, + }, + already_wrapped @ DiffError::ChangesetDiffFailed { .. } => already_wrapped, + } +} diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index e61259ec..a032f19d 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -11,4 +11,29 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(code(workon::review::git_error))] Git(#[from] git2::Error), + + /// Diff construction or acquisition failed + #[error(transparent)] + #[diagnostic(transparent)] + Diff(#[from] DiffError), +} + +/// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a +/// `workon::Changeset` (`git-workon-lib`). +#[derive(Error, Diagnostic, Debug)] +pub enum DiffError { + /// A git2 call failed while building or reading a diff/patch + #[error(transparent)] + #[diagnostic(code(workon::review::diff_git_error))] + Git(#[from] git2::Error), + + /// Diffing a changeset's resolved rev pair failed — a bad/garbage `Oid` never yields an + /// empty [`crate::model::DiffModel`], it yields this error. + #[error("failed to diff changeset '{name}'")] + #[diagnostic(code(workon::review::changeset_diff_failed))] + ChangesetDiffFailed { + name: String, + #[source] + source: git2::Error, + }, } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index f76815ae..ade2c642 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -1,11 +1,14 @@ //! Core library for `git-workon-review`, a TUI for reviewing changesets. //! -//! This library (lib target `workon_review`) will hold the review domain: -//! diff parsing, word-diff, line-precise staging, and changeset views. See -//! `docs/rfc/workon-review.md` in the workspace root for the full design. +//! This library (lib target `workon_review`) holds the review domain: diff parsing, +//! word-diff, line-precise staging, and changeset views. See `docs/rfc/workon-review.md` in +//! the workspace root for the full design. //! //! ## Status //! -//! M0 scaffolding only — no review logic exists yet. +//! M2: the diff model ([`model`]) and its acquisition from [`workon::Changeset`]s +//! ([`acquire`]) exist; synthesis, staging, and refresh land in later M2 changesets. +pub mod acquire; pub mod error; +pub mod model; diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs new file mode 100644 index 00000000..c7071935 --- /dev/null +++ b/git-workon-review/src/model.rs @@ -0,0 +1,236 @@ +//! The diff model: [`DiffModel`]/[`FileChange`]/[`Hunk`]/[`HunkLine`] built directly from +//! git2 [`git2::Diff`]/[`git2::Patch`] structures. +//! +//! Per the M2 design decision, this is NOT a unified-diff-text parser: it walks git2's own +//! line callbacks (content bytes + origin chars, including the EOFNL origins `=`/`>`/`<`) so +//! the model can byte-exactly re-render the patches it read ([`Hunk::to_diff_bytes`]). +//! +//! ## EOFNL characterization (see `tests/diff_model.rs`) +//! +//! git2 never emits a separate pseudo-line for a missing trailing newline. Instead, when a +//! real line (context/addition/deletion) is the last line of a file lacking a trailing +//! newline, git2 emits that line's content WITHOUT the newline, immediately followed by a +//! marker line whose origin is one of: +//! +//! - `ContextEOFNL` (`=`) — the preceding CONTEXT line has no trailing newline. +//! - `AddEOFNL` (`>`) — the preceding DELETION line's old-side content has no trailing +//! newline (despite the name, this marks the OLD/`-` side, not the `+` side — verified +//! empirically, do not trust the enum name). +//! - `DeleteEOFNL` (`<`) — the preceding ADDITION line's new-side content has no trailing +//! newline (again, the name is the mirror of what you'd expect). +//! +//! The marker's own content is `"\n\\ No newline at end of file\n"` — the leading `\n` +//! supplies the newline the preceding line omitted. [`DiffModel::from_git2`] does not push a +//! separate line for these markers; it sets [`HunkLine::missing_newline`] on the +//! most-recently-pushed line instead, matching the sketch in the plan. + +use crate::error::DiffError; + +/// What kind of line a [`HunkLine`] is, independent of which side of the patch it came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineKind { + Context, + Addition, + Deletion, +} + +/// One line of hunk content, carrying EXACT bytes (no trailing `\n` normalization). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HunkLine { + pub kind: LineKind, + /// Exact bytes as git2 reported them, including the trailing `\n` when present. When + /// [`missing_newline`](Self::missing_newline) is set, these bytes do NOT end in `\n` — + /// the file's last line genuinely has none. + pub content: Vec, + pub old_lnum: Option, + pub new_lnum: Option, + /// Set from the EOFNL origin markers (`=`/`>`/`<`) that git2 emits immediately after this + /// line when it is the last line of a file with no trailing newline. No pseudo-line is + /// ever pushed for the marker itself — see the module docs. + pub missing_newline: bool, +} + +/// One `@@ ... @@` hunk, re-renderable byte-for-byte via [`Hunk::to_diff_bytes`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// Verbatim `@@ -old_start,old_count +new_start,new_count @@ ...` bytes from git2, + /// including trailing `\n` — keeps any function-context suffix git2 attaches. + pub header: Vec, + pub lines: Vec, +} + +impl Hunk { + /// Byte-exact re-render of this hunk: header followed by each line's origin-prefixed + /// content, splicing in the git-canonical `\ No newline at end of file` marker (in the + /// exact byte sequence git2 uses: a bare `\n` continuing the truncated line, then the + /// marker text) wherever [`HunkLine::missing_newline`] is set. + /// + /// Fidelity is pinned against `git2::Diff::print(DiffFormat::Patch)` output in + /// `tests/diff_model.rs`. + pub fn to_diff_bytes(&self) -> Vec { + let mut out = self.header.clone(); + for line in &self.lines { + let prefix: u8 = match line.kind { + LineKind::Context => b' ', + LineKind::Addition => b'+', + LineKind::Deletion => b'-', + }; + out.push(prefix); + out.extend_from_slice(&line.content); + if line.missing_newline { + out.extend_from_slice(b"\n\\ No newline at end of file\n"); + } + } + out + } +} + +/// What kind of change a [`FileChange`] represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileStatus { + Modified, + Added, + Deleted, + Renamed, + Copied, + Untracked, + Unmerged, +} + +impl From for FileStatus { + fn from(delta: git2::Delta) -> Self { + match delta { + git2::Delta::Added => FileStatus::Added, + git2::Delta::Deleted => FileStatus::Deleted, + git2::Delta::Renamed => FileStatus::Renamed, + git2::Delta::Copied => FileStatus::Copied, + git2::Delta::Untracked => FileStatus::Untracked, + git2::Delta::Conflicted => FileStatus::Unmerged, + // Modified, Unmodified, Ignored, Typechange, Unreadable: none of these are + // distinct routing targets in the M2 model; fall back to Modified, the ordinary + // hunk-diffable case. + _ => FileStatus::Modified, + } + } +} + +/// One changed file, with its hunks (empty for binary files — see [`FileChange::is_binary`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileChange { + pub path: String, + /// The pre-change path for [`FileStatus::Renamed`]/[`FileStatus::Copied`]; `None` + /// otherwise. + pub old_path: Option, + pub status: FileStatus, + pub is_binary: bool, + pub hunks: Vec, +} + +/// A diff, built from git2 structures — see the module docs for the EOFNL characterization +/// and [`Hunk::to_diff_bytes`] for the byte-fidelity contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffModel { + pub files: Vec, +} + +impl DiffModel { + /// Build a [`DiffModel`] from a git2 [`git2::Diff`], iterating deltas and hunk-diffing + /// each non-binary one via [`git2::Patch::from_diff`]. + /// + /// Untracked deltas carry zero OIDs in git2 — this never reads blob ids off a delta; + /// content always arrives via the patch line callbacks. + pub fn from_git2(diff: &git2::Diff<'_>) -> Result { + let mut files = Vec::with_capacity(diff.deltas().len()); + for i in 0..diff.deltas().len() { + let delta = diff + .get_delta(i) + .expect("index within diff.deltas().len() is always valid"); + let status = FileStatus::from(delta.status()); + + let old_path = delta.old_file().path().map(path_to_string); + let new_path = delta.new_file().path().map(path_to_string); + let path = match status { + FileStatus::Deleted => old_path.clone(), + _ => new_path.or_else(|| old_path.clone()), + } + .unwrap_or_default(); + let old_path = match status { + FileStatus::Renamed | FileStatus::Copied => old_path, + _ => None, + }; + + // The BINARY flag on the delta fetched via `diff.get_delta` is not yet + // populated — libgit2 only runs the binary content check while computing the + // patch. Build the patch unconditionally and re-check its delta's flags. + let mut is_binary = delta.flags().contains(git2::DiffFlags::BINARY); + let mut hunks = Vec::new(); + if let Some(patch) = git2::Patch::from_diff(diff, i)? { + is_binary = is_binary || patch.delta().flags().contains(git2::DiffFlags::BINARY); + if !is_binary { + hunks = hunks_from_patch(&patch)?; + } + } + + files.push(FileChange { + path, + old_path, + status, + is_binary, + hunks, + }); + } + Ok(DiffModel { files }) + } +} + +fn path_to_string(path: &std::path::Path) -> String { + path.to_string_lossy().into_owned() +} + +fn hunks_from_patch(patch: &git2::Patch<'_>) -> Result, DiffError> { + let mut hunks = Vec::with_capacity(patch.num_hunks()); + for h in 0..patch.num_hunks() { + let (raw_hunk, line_count) = patch.hunk(h)?; + let mut lines: Vec = Vec::with_capacity(line_count); + for l in 0..line_count { + let line = patch.line_in_hunk(h, l)?; + let kind = match line.origin_value() { + git2::DiffLineType::Context => LineKind::Context, + git2::DiffLineType::Addition => LineKind::Addition, + git2::DiffLineType::Deletion => LineKind::Deletion, + git2::DiffLineType::ContextEOFNL + | git2::DiffLineType::AddEOFNL + | git2::DiffLineType::DeleteEOFNL => { + // No pseudo-line: mark the most recently pushed real line instead (see + // module docs for the EOFNL characterization). + if let Some(last) = lines.last_mut() { + last.missing_newline = true; + } + continue; + } + // FileHeader/HunkHeader/Binary never appear via `line_in_hunk`. + _ => continue, + }; + lines.push(HunkLine { + kind, + content: line.content().to_vec(), + old_lnum: line.old_lineno(), + new_lnum: line.new_lineno(), + missing_newline: false, + }); + } + hunks.push(Hunk { + old_start: raw_hunk.old_start(), + old_count: raw_hunk.old_lines(), + new_start: raw_hunk.new_start(), + new_count: raw_hunk.new_lines(), + header: raw_hunk.header().to_vec(), + lines, + }); + } + Ok(hunks) +} diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs new file mode 100644 index 00000000..fa77fa24 --- /dev/null +++ b/git-workon-review/tests/diff_model.rs @@ -0,0 +1,409 @@ +//! Model-shape and byte-fidelity tests for `workon_review::model`/`workon_review::acquire`. +//! +//! The EOFNL characterization test pins what git2 0.21 actually emits for a no-trailing-newline +//! file (plan risk #2) — this is normative for CS2/CS3's patch synthesis, not just a sanity +//! check. Fixtures used for byte assertions pin `core.autocrlf=false` so bytes are +//! platform-stable (plan risk #6). + +use git2::{BranchType, Oid, Repository}; +use git_workon_fixture::prelude::*; +use workon::{assemble_changesets, Changeset, ChangesetSource, StackModel}; +use workon_review::acquire::{diff_changeset, diff_committed, diff_uncommitted, ChangesetDiff}; +use workon_review::error::DiffError; +use workon_review::model::{FileStatus, LineKind}; + +/// Commit `path`/`content` as a child of `parent`, without moving any branch ref — callers +/// reassign a branch to the returned `Oid` via `Fixture::update_branch` themselves. Used where +/// the `deleted_file`/`unstaged_file` baseline builders don't fit (advancing one Graphite +/// branch's tip independent of `main`'s). +fn commit_onto(repo: &Repository, parent: &git2::Commit, path: &str, content: &str) -> Oid { + let mut treebuilder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap(); + let blob_oid = repo.blob(content.as_bytes()).unwrap(); + treebuilder + .insert(path, blob_oid, git2::FileMode::Blob.into()) + .unwrap(); + let tree_oid = treebuilder.write().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(None, &sig, &sig, "test commit", &tree, &[parent]) + .unwrap() +} + +// ── model shape ────────────────────────────────────────────────────────────── + +#[test] +fn staged_file_is_added_with_no_hunks_diff_needed() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.staged.files.len(), 1); + let file = &diffs.staged.files[0]; + assert_eq!(file.path, "new.txt"); + assert_eq!(file.status, FileStatus::Added); + assert!(!file.is_binary); + assert_eq!(diffs.unstaged.files.len(), 0); + + Ok(()) +} + +#[test] +fn unstaged_file_is_modified_with_one_hunk() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "tracked.txt", + "line1\nline2\nline3\n", + "line1\nCHANGED\nline3\n", + ) + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "tracked.txt"); + assert_eq!(file.status, FileStatus::Modified); + assert_eq!(file.hunks.len(), 1); + assert_eq!(diffs.staged.files.len(), 0); + + Ok(()) +} + +#[test] +fn untracked_file_has_full_content_as_addition() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "new.txt"); + assert_eq!(file.status, FileStatus::Untracked); + assert_eq!(file.hunks.len(), 1); + assert!(file.hunks[0] + .lines + .iter() + .all(|l| l.kind == LineKind::Addition)); + + Ok(()) +} + +#[test] +fn deleted_file_is_deleted_status() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "gone.txt"); + assert_eq!(file.status, FileStatus::Deleted); + + Ok(()) +} + +#[test] +fn renamed_file_carries_old_path() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build()?; + let repo = fixture.repo()?; + + let base = repo.head()?.peel_to_commit()?; + let base_oid = commit_onto(repo, &base, "old.txt", "line1\nline2\nline3\n"); + let base_commit = repo.find_commit(base_oid)?; + + // Rename: drop old.txt, add new.txt with the same (similar-enough) content. + let mut treebuilder = repo.treebuilder(Some(&base_commit.tree()?))?; + treebuilder.remove("old.txt")?; + let blob_oid = repo.blob(b"line1\nline2\nline3\n")?; + treebuilder.insert("new.txt", blob_oid, git2::FileMode::Blob.into())?; + let tree_oid = treebuilder.write()?; + let tree = repo.find_tree(tree_oid)?; + let sig = repo.signature()?; + let head_oid = repo.commit(None, &sig, &sig, "rename", &tree, &[&base_commit])?; + + let model = diff_committed(repo, base_oid, head_oid)?; + assert_eq!(model.files.len(), 1); + let file = &model.files[0]; + assert_eq!(file.status, FileStatus::Renamed); + assert_eq!(file.path, "new.txt"); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + + Ok(()) +} + +#[test] +fn binary_file_has_no_hunks() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build()?; + + let base_oid = fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 1, 2, 3, b'a', 0u8]) + .create("add binary")?; + let head_oid = fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 9, 9, 9, b'z', 0u8]) + .create("change binary")?; + + let repo = fixture.repo()?; + let model = diff_committed(repo, base_oid, head_oid)?; + assert_eq!(model.files.len(), 1); + let file = &model.files[0]; + assert_eq!(file.path, "bin.dat"); + assert!(file.is_binary); + assert!(file.hunks.is_empty()); + + Ok(()) +} + +// ── EOFNL characterization (plan risk #2 — normative for CS2/CS3) ──────────── + +/// Pins git2 0.21's actual EOFNL behavior for a file with no trailing newline whose middle +/// line changes: git2 emits the trailing context line WITHOUT its newline, immediately +/// followed by a `ContextEOFNL` ('=') marker line whose content is exactly +/// `"\n\\ No newline at end of file\n"`. No pseudo-line lands in the model — the marker sets +/// `missing_newline` on the preceding (already-pushed) context [`HunkLine`]. +#[test] +fn eofnl_context_marker_sets_missing_newline_on_preceding_line( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nline2\nline3", + "line1\nline2-changed\nline3", + ) + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!(file.hunks.len(), 1); + let lines = &file.hunks[0].lines; + + // Exactly 4 real lines: no pseudo-line for the EOFNL marker. + assert_eq!(lines.len(), 4); + + assert_eq!(lines[0].kind, LineKind::Context); + assert_eq!(lines[0].content, b"line1\n"); + assert!(!lines[0].missing_newline); + + assert_eq!(lines[1].kind, LineKind::Deletion); + assert_eq!(lines[1].content, b"line2\n"); + assert!(!lines[1].missing_newline); + + assert_eq!(lines[2].kind, LineKind::Addition); + assert_eq!(lines[2].content, b"line2-changed\n"); + assert!(!lines[2].missing_newline); + + // The trailing context line: git2 hands back content WITHOUT the newline, and the + // ContextEOFNL marker (content "\n\\ No newline at end of file\n") sets the flag instead + // of appearing as its own line. + assert_eq!(lines[3].kind, LineKind::Context); + assert_eq!(lines[3].content, b"line3"); + assert!(lines[3].missing_newline); + + Ok(()) +} + +/// Mirror of the context case, but the DELETION side (old file) lacks the trailing newline — +/// git2 emits the marker as `AddEOFNL` ('>'), despite the name marking the OLD/`-` side, not +/// the `+` side. Verified empirically; do not trust the enum name. +#[test] +fn eofnl_marker_on_deletion_side_when_old_file_lacks_trailing_newline( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3", "line1\nline2\nline3\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!(file.hunks.len(), 1); + let lines = &file.hunks[0].lines; + + // context, context, deletion(no nl, flagged), addition(with nl) — 4 real lines. + assert_eq!(lines.len(), 4); + assert_eq!(lines[2].kind, LineKind::Deletion); + assert_eq!(lines[2].content, b"line3"); + assert!(lines[2].missing_newline); + assert_eq!(lines[3].kind, LineKind::Addition); + assert_eq!(lines[3].content, b"line3\n"); + assert!(!lines[3].missing_newline); + + Ok(()) +} + +/// Mirror again: the ADDITION side (new file) lacks the trailing newline — git2 emits +/// `DeleteEOFNL` ('<'), again the mirror of what the name suggests. +#[test] +fn eofnl_marker_on_addition_side_when_new_file_lacks_trailing_newline( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nline2\nline3") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let lines = &file.hunks[0].lines; + + assert_eq!(lines.len(), 4); + assert_eq!(lines[2].kind, LineKind::Deletion); + assert_eq!(lines[2].content, b"line3\n"); + assert!(!lines[2].missing_newline); + assert_eq!(lines[3].kind, LineKind::Addition); + assert_eq!(lines[3].content, b"line3"); + assert!(lines[3].missing_newline); + + Ok(()) +} + +// ── byte-fidelity ───────────────────────────────────────────────────────────── + +/// Render the hunk-body bytes (hunk header + lines, no file header) straight off +/// `Diff::print(DiffFormat::Patch)`, the same way real diff text is produced — the reference +/// [`Hunk::to_diff_bytes`] is pinned against. +fn print_hunk_bytes(diff: &git2::Diff<'_>) -> Vec { + let mut out = Vec::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + match line.origin_value() { + git2::DiffLineType::FileHeader => {} + git2::DiffLineType::HunkHeader + | git2::DiffLineType::ContextEOFNL + | git2::DiffLineType::AddEOFNL + | git2::DiffLineType::DeleteEOFNL => { + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Context => { + out.push(b' '); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Addition => { + out.push(b'+'); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Deletion => { + out.push(b'-'); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Binary => {} + } + true + }) + .unwrap(); + out +} + +#[test] +fn hunk_to_diff_bytes_matches_diff_print() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nline2\nline3\nline4\n", + "line1\nCHANGED\nline3\nline4", + ) + .build()?; + let repo = fixture.repo()?; + + let mut opts = git2::DiffOptions::new(); + opts.context_lines(3); + let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; + + let model = workon_review::model::DiffModel::from_git2(&diff)?; + assert_eq!(model.files.len(), 1); + assert_eq!(model.files[0].hunks.len(), 1); + + let expected = print_hunk_bytes(&diff); + let actual = model.files[0].hunks[0].to_diff_bytes(); + assert_eq!( + String::from_utf8_lossy(&actual), + String::from_utf8_lossy(&expected) + ); + assert_eq!(actual, expected); + + Ok(()) +} + +// ── diff_changeset over a real assemble_changesets result ───────────────────── + +#[test] +fn diff_changeset_over_real_graphite_stack() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .build()?; + let repo = fixture.repo()?; + + let main_tip = repo + .find_branch("main", BranchType::Local)? + .get() + .target() + .unwrap(); + let main_commit = repo.find_commit(main_tip)?; + // Advance "a" independently of "main" so base != head. + let a_head = commit_onto(repo, &main_commit, "feature.txt", "hello\n"); + fixture.update_branch("a", a_head)?; + + let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let a_cs = changesets + .iter() + .find(|c| c.name == "a") + .expect("assembled changeset for 'a'"); + + match diff_changeset(repo, a_cs)? { + ChangesetDiff::Committed(model) => { + assert_eq!(model.files.len(), 1); + assert_eq!(model.files[0].path, "feature.txt"); + assert_eq!(model.files[0].status, FileStatus::Added); + } + ChangesetDiff::Uncommitted(_) => panic!("expected a Committed diff for a Graphite node"), + } + + Ok(()) +} + +#[test] +fn diff_changeset_with_bad_base_oid_fails_never_empty() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let repo = fixture.repo()?; + let head = repo.head()?.peel_to_commit()?.id(); + + let cs = Changeset { + name: "bogus".to_string(), + source: ChangesetSource::Committed { + base: Oid::ZERO_SHA1, + head, + }, + title: None, + current: false, + needs_restack: false, + }; + + let err = diff_changeset(repo, &cs).expect_err("a garbage base Oid must error, not diff empty"); + match err { + DiffError::ChangesetDiffFailed { name, .. } => assert_eq!(name, "bogus"), + other => panic!("expected ChangesetDiffFailed, got {other:?}"), + } + + Ok(()) +} From 4db37fddaabd89e63b3b9b493703eb6d4077ad3b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:21:58 -0400 Subject: [PATCH 05/18] feat(review): synthesize whole-hunk patches from the model --- git-workon-review/src/error.rs | 35 +++ git-workon-review/src/lib.rs | 6 +- git-workon-review/src/synthesis.rs | 447 +++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/synthesis.rs diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index a032f19d..565d235e 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -1,6 +1,8 @@ use miette::Diagnostic; use thiserror::Error; +use crate::model::FileStatus; + /// Result type alias using ReviewError pub type Result = std::result::Result; @@ -16,6 +18,11 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(transparent)] Diff(#[from] DiffError), + + /// Patch synthesis from the diff model failed + #[error(transparent)] + #[diagnostic(transparent)] + Synthesis(#[from] SynthesisError), } /// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a @@ -37,3 +44,31 @@ pub enum DiffError { source: git2::Error, }, } + +/// Errors synthesizing a [`crate::synthesis::PatchText`] from a [`crate::model::FileChange`]. +#[derive(Error, Diagnostic, Debug)] +pub enum SynthesisError { + /// No lines were kept for the patch — nothing to apply. + #[error("no lines selected to synthesize a patch for '{path}' hunk {hunk}")] + #[diagnostic(code(workon::review::empty_selection))] + EmptySelection { path: String, hunk: usize }, + + /// `hunk_idx` didn't name a hunk on the file. + #[error("hunk index {index} out of range for '{path}'")] + #[diagnostic(code(workon::review::hunk_out_of_range))] + HunkOutOfRange { path: String, index: usize }, + + /// The file's status can't be expressed as a hunk patch (trap 3: whole-file ops route + /// around synthesis entirely; this is what a caller sees if it reaches synthesis anyway). + #[error("line-precise selection is not supported for '{path}' ({status:?})")] + #[diagnostic( + code(workon::review::line_selection_unsupported), + help("stage/unstage/discard the whole file instead") + )] + LineSelectionUnsupported { path: String, status: FileStatus }, + + /// The file is binary — there are no hunks to synthesize a patch from. + #[error("'{path}' is a binary file and cannot be patched by hunk")] + #[diagnostic(code(workon::review::binary_file))] + BinaryFile { path: String }, +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index ade2c642..f79420d8 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -6,9 +6,11 @@ //! //! ## Status //! -//! M2: the diff model ([`model`]) and its acquisition from [`workon::Changeset`]s -//! ([`acquire`]) exist; synthesis, staging, and refresh land in later M2 changesets. +//! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s +//! ([`acquire`]), and whole-hunk patch synthesis ([`synthesis`]) exist; the apply chokepoint, +//! line-precise synthesis, file ops, staging, and refresh land in later M2 changesets. pub mod acquire; pub mod error; pub mod model; +pub mod synthesis; diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs new file mode 100644 index 00000000..8f3eaa89 --- /dev/null +++ b/git-workon-review/src/synthesis.rs @@ -0,0 +1,447 @@ +//! Synthesizing invertible patch text from a [`crate::model::DiffModel`]. +//! +//! git2's write side takes bytes ([`git2::Diff::from_buffer`]), and the `git apply` CLI takes +//! text on stdin — but libgit2's `Repository::apply` has NO reverse flag (plan risk #1). A +//! "reverse apply" is therefore always: synthesize the forward patch, then +//! [`PatchText::invert`] it before handing it to an applier. [`PatchText`] stays structured +//! (not opaque bytes) so that inversion is a pure, testable transform instead of a text +//! rewrite. +//! +//! This module only synthesizes WHOLE hunks (`[whole_hunk_patch]`). Line-precise synthesis +//! (traps 1-2: direction-dependent drop rules, the EOFNL splice) lands in CS3 +//! (`partial_hunk_patch`). + +use crate::error::SynthesisError; +use crate::model::{FileChange, FileStatus, LineKind}; + +/// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) +/// key off this. Whole-hunk patches (this module) don't drop lines, so `PatchBase` is +/// currently only consumed by [`crate::apply::StageVerb::plan`]; line-precise synthesis (CS3) +/// is where it drives which lines get kept vs. converted to context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchBase { + Old, + New, +} + +/// One line of a synthesized patch — mirrors [`crate::model::HunkLine`] minus the line-number +/// bookkeeping a patch doesn't need to render. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchLine { + pub kind: LineKind, + pub content: Vec, + pub missing_newline: bool, +} + +/// One `@@ ... @@` hunk of a [`PatchText`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchHunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// Verbatim `@@ -old_start,old_count +new_start,new_count @@ ...` bytes (including + /// trailing `\n`) for a freshly synthesized (non-inverted) hunk — reused as-is from + /// [`crate::model::Hunk::header`] so any function-context suffix git2 attached survives. + /// [`PatchHunk`] rebuilds this field with swapped numbers (preserving the suffix) when + /// inverted; see [`PatchText::invert`]. + pub header: Vec, + pub lines: Vec, +} + +impl PatchHunk { + /// Render this hunk's bytes: header, then each line's origin-prefixed content, splicing + /// in the `\ No newline at end of file` marker wherever [`PatchLine::missing_newline`] is + /// set — byte-identical algorithm to [`crate::model::Hunk::to_diff_bytes`], since + /// [`whole_hunk_patch`] copies a model hunk's lines verbatim. + fn to_bytes(&self) -> Vec { + let mut out = self.header.clone(); + for line in &self.lines { + let prefix: u8 = match line.kind { + LineKind::Context => b' ', + LineKind::Addition => b'+', + LineKind::Deletion => b'-', + }; + out.push(prefix); + out.extend_from_slice(&line.content); + if line.missing_newline { + out.extend_from_slice(b"\n\\ No newline at end of file\n"); + } + } + out + } + + /// Swap old/new starts+counts, flip Addition<->Deletion (Context stays), and rebuild the + /// header text around the swapped numbers while preserving whatever trailing bytes + /// followed the second `@@` marker (a function-context suffix, or just `\n`). + fn invert(&self) -> PatchHunk { + let suffix = header_suffix(&self.header); + let header = format!( + "@@ -{},{} +{},{} @@", + self.new_start, self.new_count, self.old_start, self.old_count + ) + .into_bytes(); + let mut header = header; + header.extend_from_slice(&suffix); + + let lines = self + .lines + .iter() + .map(|line| PatchLine { + kind: match line.kind { + LineKind::Addition => LineKind::Deletion, + LineKind::Deletion => LineKind::Addition, + LineKind::Context => LineKind::Context, + }, + content: line.content.clone(), + missing_newline: line.missing_newline, + }) + .collect(); + + PatchHunk { + old_start: self.new_start, + old_count: self.new_count, + new_start: self.old_start, + new_count: self.old_count, + header, + lines, + } + } +} + +/// Everything after the second `@@` in a hunk header, e.g. `" fn foo() {\n"` or just `"\n"`. +fn header_suffix(header: &[u8]) -> Vec { + let find = |haystack: &[u8], needle: &[u8]| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }; + if let Some(first) = find(header, b"@@") { + if let Some(second_rel) = find(&header[first + 2..], b"@@") { + let second = first + 2 + second_rel; + return header[second + 2..].to_vec(); + } + } + b"\n".to_vec() +} + +/// A structured, invertible patch — the render/parse boundary between the model and the +/// appliers. `old_path`/`new_path` are `None` for a `/dev/null` side (whole-file +/// creation/deletion); [`whole_hunk_patch`] always sets both, since it only synthesizes +/// Modified/Renamed files. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchText { + pub old_path: Option, + pub new_path: Option, + pub hunks: Vec, +} + +impl PatchText { + /// Render the full patch: a `diff --git`/`index`/`---`/`+++` file header, then each + /// hunk's bytes. Always ends in `\n` (each hunk's last line is either a real line with its + /// own trailing `\n`, or a `missing_newline` line whose marker supplies one). + /// + /// The `index 0000000..0000000 100644` line is a placeholder — this crate never reads + /// blob OIDs off the model (untracked deltas don't have them either), and `git apply` + /// ignores it. It exists because `git2::Diff::from_buffer` parses stricter than `git + /// apply` and rejects a bare 3-line header (plan risk #4). + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + let diff_git_old = self + .old_path + .as_deref() + .or(self.new_path.as_deref()) + .unwrap_or(""); + let diff_git_new = self + .new_path + .as_deref() + .or(self.old_path.as_deref()) + .unwrap_or(""); + out.extend_from_slice(format!("diff --git a/{diff_git_old} b/{diff_git_new}\n").as_bytes()); + out.extend_from_slice(b"index 0000000..0000000 100644\n"); + let old_label = match &self.old_path { + Some(p) => format!("a/{p}"), + None => "/dev/null".to_string(), + }; + let new_label = match &self.new_path { + Some(p) => format!("b/{p}"), + None => "/dev/null".to_string(), + }; + out.extend_from_slice(format!("--- {old_label}\n").as_bytes()); + out.extend_from_slice(format!("+++ {new_label}\n").as_bytes()); + for hunk in &self.hunks { + out.extend_from_slice(&hunk.to_bytes()); + } + out + } + + /// Pure transform: swap old/new paths and invert every hunk (trap 1's Old/New base swap, + /// applied wholesale). Needed because `Repository::apply` has no reverse flag — a + /// "reverse apply" is `invert()` then a forward apply. `invert(invert(p)) == p` (tested). + pub fn invert(&self) -> PatchText { + PatchText { + old_path: self.new_path.clone(), + new_path: self.old_path.clone(), + hunks: self.hunks.iter().map(PatchHunk::invert).collect(), + } + } +} + +/// Synthesize a patch for the WHOLE of `file`'s hunk at `hunk_idx` — no line selection, so the +/// direction-dependent drop rules (trap 1) don't apply; the hunk's lines are copied verbatim. +/// +/// Refuses: +/// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. +/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). +/// - statuses a hunk patch can't express ([`SynthesisError::LineSelectionUnsupported`]): +/// `Added`/`Deleted`/`Untracked`/`Unmerged` are whole-file operations by nature — a hunk +/// patch of a deletion would stage an empty blob instead of removing the file, and a hunk +/// patch of an untracked file has no index/HEAD preimage to apply against (trap 3). CS4's +/// `ops.rs` routes these statuses to `file_ops.rs` before synthesis is ever reached, so +/// `LineSelectionUnsupported` is the variant callers see here — it's the closest existing +/// error to "use the whole-file op instead," which is exactly its `help` text. +/// `Copied` is treated like `Renamed` (both carry an `old_path`). +pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result { + if file.is_binary { + return Err(SynthesisError::BinaryFile { + path: file.path.clone(), + }); + } + match file.status { + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} + other => { + return Err(SynthesisError::LineSelectionUnsupported { + path: file.path.clone(), + status: other, + }) + } + } + let hunk = file + .hunks + .get(hunk_idx) + .ok_or_else(|| SynthesisError::HunkOutOfRange { + path: file.path.clone(), + index: hunk_idx, + })?; + + let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); + let new_path = file.path.clone(); + let lines = hunk + .lines + .iter() + .map(|line| PatchLine { + kind: line.kind, + content: line.content.clone(), + missing_newline: line.missing_newline, + }) + .collect(); + + Ok(PatchText { + old_path: Some(old_path), + new_path: Some(new_path), + hunks: vec![PatchHunk { + old_start: hunk.old_start, + old_count: hunk.old_count, + new_start: hunk.new_start, + new_count: hunk.new_count, + header: hunk.header.clone(), + lines, + }], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Hunk, HunkLine}; + + fn modified_file(hunk: Hunk) -> FileChange { + FileChange { + path: "f.txt".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: false, + hunks: vec![hunk], + } + } + + fn simple_hunk() -> Hunk { + Hunk { + old_start: 1, + old_count: 3, + new_start: 1, + new_count: 3, + header: b"@@ -1,3 +1,3 @@\n".to_vec(), + lines: vec![ + HunkLine { + kind: LineKind::Context, + content: b"line1\n".to_vec(), + old_lnum: Some(1), + new_lnum: Some(1), + missing_newline: false, + }, + HunkLine { + kind: LineKind::Deletion, + content: b"line2\n".to_vec(), + old_lnum: Some(2), + new_lnum: None, + missing_newline: false, + }, + HunkLine { + kind: LineKind::Addition, + content: b"CHANGED\n".to_vec(), + old_lnum: None, + new_lnum: Some(2), + missing_newline: false, + }, + HunkLine { + kind: LineKind::Context, + content: b"line3\n".to_vec(), + old_lnum: Some(3), + new_lnum: Some(3), + missing_newline: false, + }, + ], + } + } + + #[test] + fn whole_hunk_patch_renders_exact_bytes() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,3 +1,3 @@\n", + " line1\n", + "-line2\n", + "+CHANGED\n", + " line3\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn whole_hunk_render_body_matches_model_hunk_to_diff_bytes() { + let hunk = simple_hunk(); + let file = modified_file(hunk.clone()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + // Strip the file header (4 lines: diff --git/index/---/+++) to compare just the hunk + // body against the model's own byte-fidelity contract. + let rendered = patch.to_bytes(); + let body_start = rendered + .windows(2) + .position(|w| w == b"@@") + .expect("hunk header present"); + let body = &rendered[body_start..]; + + assert_eq!(body, hunk.to_diff_bytes().as_slice()); + } + + #[test] + fn invert_of_invert_is_identity() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + assert_eq!(patch.invert().invert(), patch); + } + + #[test] + fn invert_swaps_paths_and_line_kinds() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + let inverted = patch.invert(); + + assert_eq!(inverted.old_path, patch.new_path); + assert_eq!(inverted.new_path, patch.old_path); + assert_eq!(inverted.hunks[0].old_start, patch.hunks[0].new_start); + assert_eq!(inverted.hunks[0].new_start, patch.hunks[0].old_start); + assert_eq!(inverted.hunks[0].lines[1].kind, LineKind::Addition); + assert_eq!(inverted.hunks[0].lines[2].kind, LineKind::Deletion); + // Content and missing_newline travel with the line, unchanged. + assert_eq!(inverted.hunks[0].lines[1].content, b"line2\n"); + } + + #[test] + fn invert_moves_missing_newline_marker_with_its_line() { + let mut hunk = simple_hunk(); + // The deletion (old side) has no trailing newline. + hunk.lines[1].content = b"line2".to_vec(); + hunk.lines[1].missing_newline = true; + let file = modified_file(hunk); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + let inverted = patch.invert(); + // The deletion becomes an addition in the inverted patch, carrying the flag with it. + assert_eq!(inverted.hunks[0].lines[1].kind, LineKind::Addition); + assert!(inverted.hunks[0].lines[1].missing_newline); + assert_eq!(inverted.hunks[0].lines[1].content, b"line2"); + } + + #[test] + fn refuses_binary_file() { + let file = FileChange { + path: "bin.dat".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: true, + hunks: vec![], + }; + assert!(matches!( + whole_hunk_patch(&file, 0), + Err(SynthesisError::BinaryFile { .. }) + )); + } + + #[test] + fn refuses_hunk_index_out_of_range() { + let file = modified_file(simple_hunk()); + assert!(matches!( + whole_hunk_patch(&file, 1), + Err(SynthesisError::HunkOutOfRange { .. }) + )); + } + + #[test] + fn refuses_statuses_a_hunk_patch_cannot_express() { + for status in [ + FileStatus::Added, + FileStatus::Deleted, + FileStatus::Untracked, + FileStatus::Unmerged, + ] { + let file = FileChange { + path: "f.txt".to_string(), + old_path: None, + status, + is_binary: false, + hunks: vec![simple_hunk()], + }; + assert!( + matches!( + whole_hunk_patch(&file, 0), + Err(SynthesisError::LineSelectionUnsupported { .. }) + ), + "expected refusal for status {status:?}" + ); + } + } + + #[test] + fn renamed_file_uses_old_path_in_header() { + let mut file = modified_file(simple_hunk()); + file.status = FileStatus::Renamed; + file.old_path = Some("old.txt".to_string()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + assert_eq!(patch.old_path.as_deref(), Some("old.txt")); + assert_eq!(patch.new_path.as_deref(), Some("f.txt")); + } +} From e62cf4fe3dcea0a73d1ac19919bc0dedd49d02c3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:26:23 -0400 Subject: [PATCH 06/18] feat(review): add patch applier over git2 and git CLI --- git-workon-review/src/apply.rs | 191 +++++++++++++++++++++++++++++++ git-workon-review/src/error.rs | 39 +++++++ git-workon-review/src/lib.rs | 6 +- git-workon-review/tests/apply.rs | 185 ++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/apply.rs create mode 100644 git-workon-review/tests/apply.rs diff --git a/git-workon-review/src/apply.rs b/git-workon-review/src/apply.rs new file mode 100644 index 00000000..49593853 --- /dev/null +++ b/git-workon-review/src/apply.rs @@ -0,0 +1,191 @@ +//! Applying a [`PatchText`] to a repository's index or working tree — the one chokepoint +//! (per the M2 design decision) parameterizable over two backends: [`Git2Applier`] (libgit2's +//! `Repository::apply`) and [`CliApplier`] (`git apply` on stdin). The round-trip corpus (CS6) +//! runs every scenario against both; `CliApplier` is the oracle. +//! +//! ## The flag matrix (trap 1's chokepoint, prototype-verified) +//! +//! `git apply` takes ONLY `--cached`/`--reverse`, patch on stdin — never `--unidiff-zero`, +//! never `--3way`. [`StageVerb::plan`] encodes the same matrix for both backends: +//! +//! | verb | patch base | destination | direction | +//! |---------|------------|-------------|-----------| +//! | Stage | Old | Index | Forward | +//! | Unstage | New | Index | Reverse | +//! | Discard | New | Workdir | Reverse | +//! +//! ## `ApplyLocation::Index` preimage (plan risk #3) +//! +//! The index is not HEAD. A Stage patch must be synthesized from the unstaged model +//! (`index_to_workdir` — old side is the INDEX); an Unstage patch must be synthesized from the +//! staged model (`tree_to_index` — old side is HEAD). Feeding the wrong model's patch to +//! `ApplyLocation::Index` is the classic corruption source. Never `ApplyLocation::Both`. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use git2::Repository; + +use crate::error::ApplyError; +use crate::synthesis::PatchText; + +/// Where a patch is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyDestination { + Index, + Workdir, +} + +/// Whether the patch is applied as synthesized, or inverted first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyDirection { + Forward, + Reverse, +} + +/// The three staging actions a review session performs. [`StageVerb::plan`] is the flag +/// matrix above, encoded once so `ops.rs` (CS4) and the applier tests share one source of +/// truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StageVerb { + Stage, + Unstage, + Discard, +} + +impl StageVerb { + /// `Stage`->(Old, Index, Forward); `Unstage`->(New, Index, Reverse); + /// `Discard`->(New, Workdir, Reverse). + pub fn plan( + self, + ) -> ( + crate::synthesis::PatchBase, + ApplyDestination, + ApplyDirection, + ) { + use crate::synthesis::PatchBase; + match self { + StageVerb::Stage => ( + PatchBase::Old, + ApplyDestination::Index, + ApplyDirection::Forward, + ), + StageVerb::Unstage => ( + PatchBase::New, + ApplyDestination::Index, + ApplyDirection::Reverse, + ), + StageVerb::Discard => ( + PatchBase::New, + ApplyDestination::Workdir, + ApplyDirection::Reverse, + ), + } + } +} + +/// A patch-application backend. [`Git2Applier`] and [`CliApplier`] both implement this over +/// the same [`PatchText`] — the round-trip corpus drives whichever `dyn Applier` it's handed. +pub trait Applier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError>; +} + +/// Applies via libgit2's `Repository::apply`. `Reverse` is [`PatchText::invert`] followed by a +/// forward apply — `Repository::apply` itself has no reverse flag (plan risk #1). +pub struct Git2Applier; + +impl Applier for Git2Applier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError> { + let bytes = match dir { + ApplyDirection::Forward => patch.to_bytes(), + ApplyDirection::Reverse => patch.invert().to_bytes(), + }; + let diff = git2::Diff::from_buffer(&bytes)?; + let location = match dest { + ApplyDestination::Index => git2::ApplyLocation::Index, + ApplyDestination::Workdir => git2::ApplyLocation::WorkDir, + }; + repo.apply(&diff, location, None)?; + Ok(()) + } +} + +/// Applies by spawning `git apply` with the patch on stdin, cwd set to the repository's +/// working directory. `Index` destination -> `--cached`; `Reverse` direction -> `--reverse`. +/// Never `--unidiff-zero`, never `--3way` (prototype chokepoint, trap 1). +pub struct CliApplier; + +impl Applier for CliApplier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("CliApplier requires a repository with a working directory"); + + let mut args = vec!["apply".to_string()]; + if dest == ApplyDestination::Index { + args.push("--cached".to_string()); + } + if dir == ApplyDirection::Reverse { + args.push("--reverse".to_string()); + } + + let mut child = Command::new("git") + .args(&args) + .current_dir(workdir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(ApplyError::GitSpawn)?; + + child + .stdin + .as_mut() + .expect("stdin was piped") + .write_all(&patch.to_bytes()) + .map_err(ApplyError::GitSpawn)?; + + let output = child.wait_with_output().map_err(ApplyError::GitSpawn)?; + if !output.status.success() { + return Err(ApplyError::CliApplyFailed { + args, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(()) + } +} + +/// Classify an [`ApplyError`] as index-lock contention (plan risk #8), spanning both backends: +/// git2's `ErrorCode::Locked`, or its `Index`/`Os` error classes with "lock" in the message; +/// the CLI backend via `"index.lock"` in `git apply`'s stderr. +pub fn is_lock_contention(err: &ApplyError) -> bool { + match err { + ApplyError::Git(e) => { + e.code() == git2::ErrorCode::Locked + || ((e.class() == git2::ErrorClass::Index || e.class() == git2::ErrorClass::Os) + && e.message().to_lowercase().contains("lock")) + } + ApplyError::IndexLocked { .. } => true, + ApplyError::CliApplyFailed { stderr, .. } => stderr.contains("index.lock"), + ApplyError::GitSpawn(_) | ApplyError::Io { .. } => false, + } +} diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index 565d235e..9b1dee06 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -23,6 +23,11 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(transparent)] Synthesis(#[from] SynthesisError), + + /// Applying a synthesized patch failed + #[error(transparent)] + #[diagnostic(transparent)] + Apply(#[from] ApplyError), } /// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a @@ -72,3 +77,37 @@ pub enum SynthesisError { #[diagnostic(code(workon::review::binary_file))] BinaryFile { path: String }, } + +/// Errors applying a [`crate::synthesis::PatchText`] via a [`crate::apply::Applier`]. +#[derive(Error, Diagnostic, Debug)] +pub enum ApplyError { + /// A git2 call failed while applying a patch + #[error(transparent)] + #[diagnostic(code(workon::review::apply_git_error))] + Git(#[from] git2::Error), + + /// The index stayed locked across every retry (see `queue.rs`'s retry-once policy). + #[error("index locked after {attempts} attempt(s)")] + #[diagnostic(code(workon::review::index_locked))] + IndexLocked { attempts: u32 }, + + /// `git apply` exited non-zero. + #[error("git apply failed (args: {args:?}): {stderr}")] + #[diagnostic(code(workon::review::cli_apply_failed))] + CliApplyFailed { args: Vec, stderr: String }, + + /// Spawning or communicating with the `git` subprocess failed (not a nonzero exit — that's + /// [`ApplyError::CliApplyFailed`]). + #[error("failed to spawn or communicate with git")] + #[diagnostic(code(workon::review::git_spawn_failed))] + GitSpawn(#[source] std::io::Error), + + /// A whole-file operation's filesystem I/O failed (`file_ops.rs`, CS4). + #[error("file operation on '{path}' failed")] + #[diagnostic(code(workon::review::file_op_io))] + Io { + path: String, + #[source] + source: std::io::Error, + }, +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index f79420d8..1a402b33 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -7,10 +7,12 @@ //! ## Status //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s -//! ([`acquire`]), and whole-hunk patch synthesis ([`synthesis`]) exist; the apply chokepoint, -//! line-precise synthesis, file ops, staging, and refresh land in later M2 changesets. +//! ([`acquire`]), whole-hunk patch synthesis ([`synthesis`]), and the apply chokepoint +//! ([`apply`]) exist; line-precise synthesis, file ops, staging, and refresh land in later M2 +//! changesets. pub mod acquire; +pub mod apply; pub mod error; pub mod model; pub mod synthesis; diff --git a/git-workon-review/tests/apply.rs b/git-workon-review/tests/apply.rs new file mode 100644 index 00000000..91dba077 --- /dev/null +++ b/git-workon-review/tests/apply.rs @@ -0,0 +1,185 @@ +//! Whole-hunk apply round-trips, run against BOTH `Git2Applier` and `CliApplier` via +//! `for_each_applier` (plan trap 6: CLI is the oracle, git2 is re-verified against it). Each +//! test builds a FRESH fixture per applier — appliers mutate live repository state, so sharing +//! one fixture across both runs would let the second applier's assertions depend on the +//! first's side effects. +//! +//! Fixtures pin `core.autocrlf=false` so index/workdir byte assertions are platform-stable +//! (plan risk #6). + +use std::path::Path; + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{is_lock_contention, Applier, CliApplier, Git2Applier, StageVerb}; +use workon_review::error::ApplyError; +use workon_review::synthesis::whole_hunk_patch; + +/// Run `test` once per applier backend. Each invocation gets its own closure body so callers +/// build a fresh fixture inside `test` rather than sharing one across backends. +fn for_each_applier(mut test: impl FnMut(&dyn Applier)) { + test(&Git2Applier); + test(&CliApplier); +} + +#[test] +fn stage_whole_hunk_updates_index_and_leaves_workdir_untouched() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); + // The Index-only apply must not touch the working tree, which still carries the + // original unstaged modification. + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); + }); +} + +#[test] +fn unstage_whole_hunk_reverts_index_to_head_content() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + // Stage the modification directly (index := workdir content for this path) so the + // staged (tree_to_index) model sees the same hunk the unstaged model saw — the + // unstage patch's preimage must be the INDEX, per plan risk #3. + let mut index = repo.index().expect("index"); + index.add_path(Path::new("f.txt")).expect("add_path"); + index.write().expect("index write"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Unstage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); + }); +} + +#[test] +fn discard_whole_hunk_reverts_workdir_to_committed_content() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); + }); +} + +/// First live proof that the marker rendering ([`workon_review::synthesis::PatchHunk`], +/// carrying `\ No newline at end of file`) applies cleanly through a real applier: stage a +/// hunk whose new side lacks the trailing newline, and assert the exact (newline-less) index +/// bytes. +#[test] +fn stage_whole_hunk_with_missing_trailing_newline_on_new_side() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nline2\nline3") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nline2\nline3".to_vec(), + )); + }); +} + +/// Plan risk #8: lock classification spans `ErrorCode::Locked` and class `Index`/`Os` with +/// "lock" in the message (git2), or `"index.lock"` in stderr (CLI). `Repository::apply` locks +/// the index only while writing it, so a pre-existing `index.lock` file may or may not trip +/// git2's own preflight — if it doesn't, this manufactures the error git2 would raise on real +/// contention and asserts the classifier handles it, documenting the observed behavior either +/// way rather than silently no-op'ing. +#[test] +fn index_lock_contention_is_classified() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let lock_path = repo.path().join("index.lock"); + std::fs::write(&lock_path, b"").expect("create index.lock"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + let result = Git2Applier.apply(repo, &patch, dest, dir); + + std::fs::remove_file(&lock_path).ok(); + + match result { + Err(err) => { + assert!( + is_lock_contention(&err), + "expected a lock-contention error, got {err:?}" + ); + } + Ok(()) => { + // Repository::apply didn't trip over the pre-existing lock file (it locks only + // while writing, and this apply may not have needed to touch the index lock at + // the moment it checked) — manufacture the error libgit2 raises on real + // contention and prove the classifier itself is correct. + let manufactured = ApplyError::Git(git2::Error::new( + git2::ErrorCode::Locked, + git2::ErrorClass::Index, + "failed to lock file for writing", + )); + assert!(is_lock_contention(&manufactured)); + } + } +} From 83d1674253f4a6a4739ba5ea77c17d19ac94adc9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:26:14 -0400 Subject: [PATCH 07/18] fix(review): preserve real file mode when synthesizing patches --- git-workon-review/src/model.rs | 12 ++++++ git-workon-review/src/synthesis.rs | 62 +++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs index c7071935..701d5021 100644 --- a/git-workon-review/src/model.rs +++ b/git-workon-review/src/model.rs @@ -127,6 +127,16 @@ pub struct FileChange { pub old_path: Option, pub status: FileStatus, pub is_binary: bool, + /// Raw octal file mode (e.g. `0o100644`, `0o100755`) of the pre-image, from + /// `delta.old_file().mode()`. Carried alongside [`Self::new_mode`] so + /// [`crate::synthesis::whole_hunk_patch`] can pick the right mode for the patch's + /// direction — and [`crate::synthesis::PatchText::invert`] can swap them — instead of + /// clobbering the index entry's mode with a hardcoded `100644` (a real divergence: staging + /// any hunk of an executable file via the git2 applier used to silently reset it). + pub old_mode: i32, + /// Raw octal file mode of the post-image, from `delta.new_file().mode()`. See + /// [`Self::old_mode`]. + pub new_mode: i32, pub hunks: Vec, } @@ -180,6 +190,8 @@ impl DiffModel { old_path, status, is_binary, + old_mode: i32::from(delta.old_file().mode()), + new_mode: i32::from(delta.new_file().mode()), hunks, }); } diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 8f3eaa89..6adf35db 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -133,6 +133,13 @@ fn header_suffix(header: &[u8]) -> Vec { pub struct PatchText { pub old_path: Option, pub new_path: Option, + /// Raw octal mode of the pre-image (see [`FileChange::old_mode`]); swapped with + /// [`Self::new_mode`] by [`Self::invert`]. + pub old_mode: i32, + /// Raw octal mode of the post-image (see [`FileChange::new_mode`]) — this is the mode + /// written into the synthesized `index` line, since a forward patch's target state is the + /// post-image. + pub new_mode: i32, pub hunks: Vec, } @@ -141,10 +148,15 @@ impl PatchText { /// hunk's bytes. Always ends in `\n` (each hunk's last line is either a real line with its /// own trailing `\n`, or a `missing_newline` line whose marker supplies one). /// - /// The `index 0000000..0000000 100644` line is a placeholder — this crate never reads - /// blob OIDs off the model (untracked deltas don't have them either), and `git apply` - /// ignores it. It exists because `git2::Diff::from_buffer` parses stricter than `git - /// apply` and rejects a bare 3-line header (plan risk #4). + /// The `index 0000000..0000000 ` line's OIDs are a placeholder — this crate never + /// reads blob OIDs off the model (untracked deltas don't have them either), and `git + /// apply` ignores them. The line exists because `git2::Diff::from_buffer` parses stricter + /// than `git apply` and rejects a bare 3-line header (plan risk #4). The MODE, however, is + /// load-bearing: `Repository::apply(ApplyLocation::Index, ..)` takes the new index entry's + /// mode straight from this line, so it must be the file's real mode + /// ([`Self::new_mode`]) — a hardcoded `100644` here used to silently clobber the exec bit + /// of any staged `100755` file (the `git apply` CLI path never had this bug: it reads the + /// mode from the working tree instead). pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); let diff_git_old = self @@ -158,7 +170,7 @@ impl PatchText { .or(self.old_path.as_deref()) .unwrap_or(""); out.extend_from_slice(format!("diff --git a/{diff_git_old} b/{diff_git_new}\n").as_bytes()); - out.extend_from_slice(b"index 0000000..0000000 100644\n"); + out.extend_from_slice(format!("index 0000000..0000000 {:06o}\n", self.new_mode).as_bytes()); let old_label = match &self.old_path { Some(p) => format!("a/{p}"), None => "/dev/null".to_string(), @@ -182,6 +194,8 @@ impl PatchText { PatchText { old_path: self.new_path.clone(), new_path: self.old_path.clone(), + old_mode: self.new_mode, + new_mode: self.old_mode, hunks: self.hunks.iter().map(PatchHunk::invert).collect(), } } @@ -239,6 +253,8 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Date: Mon, 6 Jul 2026 00:38:04 -0400 Subject: [PATCH 08/18] feat(review): line-precise patch synthesis with direction rules --- git-workon-review/src/synthesis.rs | 390 +++++++++++++++++++++- git-workon-review/tests/line_synthesis.rs | 184 ++++++++++ 2 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 git-workon-review/tests/line_synthesis.rs diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 6adf35db..df78e84b 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -7,17 +7,18 @@ //! (not opaque bytes) so that inversion is a pure, testable transform instead of a text //! rewrite. //! -//! This module only synthesizes WHOLE hunks (`[whole_hunk_patch]`). Line-precise synthesis -//! (traps 1-2: direction-dependent drop rules, the EOFNL splice) lands in CS3 -//! (`partial_hunk_patch`). +//! This module synthesizes WHOLE hunks (`[whole_hunk_patch]`) and line-precise selections +//! (`[partial_hunk_patch]`, traps 1-2: direction-dependent drop rules, the EOFNL splice). + +use std::collections::BTreeSet; use crate::error::SynthesisError; use crate::model::{FileChange, FileStatus, LineKind}; /// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) -/// key off this. Whole-hunk patches (this module) don't drop lines, so `PatchBase` is -/// currently only consumed by [`crate::apply::StageVerb::plan`]; line-precise synthesis (CS3) -/// is where it drives which lines get kept vs. converted to context. +/// key off this. Whole-hunk patches don't drop lines, so `PatchBase` only affects +/// [`partial_hunk_patch`] (and is otherwise threaded through by [`crate::apply::StageVerb::plan`] +/// to pick which model a caller synthesizes from). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PatchBase { Old, @@ -266,6 +267,186 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result, + pub keep_dels: BTreeSet, +} + +/// Synthesize a patch for a LINE-PRECISE selection of `file`'s hunk at `hunk_idx` — the +/// direction-dependent drop rules (trap 1). +/// +/// Context lines are always emitted as context. For the rest, `base` decides what happens to a +/// line that ISN'T kept: +/// +/// - `base == Old` (forward apply — [`crate::apply::StageVerb::Stage`], staging into an index +/// that doesn't have the change yet): a dropped addition is OMITTED (the index shouldn't +/// gain it); a dropped deletion becomes CONTEXT (the index should keep what's still there). +/// - `base == New` (reverse apply — [`crate::apply::StageVerb::Unstage`]/[`Discard`], where the +/// apply target ALREADY has the change and reverse-applying undoes the kept lines): a dropped +/// addition becomes CONTEXT (it must stay in the target, so it has to match on reverse-apply +/// just like an untouched line does); a dropped deletion is OMITTED (it's already absent from +/// the target, so it must never be matched against). This is the mirror of the `Old` rules, +/// not merely a coincidence: whichever side already contains the "dropped" line is the side +/// the patch's context has to agree with, and `base` names that side. +/// +/// [`crate::apply::CliApplier`]/[`crate::apply::Git2Applier`] reverse-apply by adding +/// `--reverse` or by [`PatchText::invert`]ing before a forward apply — either way the patch +/// itself is always WRITTEN in forward orientation with the rules above; a `base == Old` +/// patch fed through a reverse apply is a different, incompatible set of drop rules and git +/// rejects it outright (see the tripwire test in `tests/line_synthesis.rs`). +/// +/// [`LineSelection`] entries that don't name an add/del line in this hunk are ignored (see +/// [`LineSelection`]'s docs). If, after ignoring those, no addition and no deletion ended up +/// kept, there is nothing to synthesize a patch for: [`SynthesisError::EmptySelection`]. +/// +/// Counts are recomputed per emitted line (context, converted-to-context, kept-add, kept-del +/// all bump the relevant side(s)); the header is rebuilt as +/// `@@ -old_start,old_count +new_start,new_count @@` plus the source hunk's header suffix +/// (reused via [`header_suffix`]) — the starts are unchanged, only the counts move. +/// +/// Same refusals as [`whole_hunk_patch`]: binary files ([`SynthesisError::BinaryFile`]), +/// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range +/// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). +/// +/// This function does not yet apply the trap-2 EOFNL splice (a dropped deletion converted to +/// context that carries [`crate::model::HunkLine::missing_newline`], followed by any kept +/// line, silently corrupts the blob under `git apply`) — see the follow-up commit. +pub fn partial_hunk_patch( + file: &FileChange, + hunk_idx: usize, + sel: &LineSelection, + base: PatchBase, +) -> Result { + if file.is_binary { + return Err(SynthesisError::BinaryFile { + path: file.path.clone(), + }); + } + match file.status { + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} + other => { + return Err(SynthesisError::LineSelectionUnsupported { + path: file.path.clone(), + status: other, + }) + } + } + let hunk = file + .hunks + .get(hunk_idx) + .ok_or_else(|| SynthesisError::HunkOutOfRange { + path: file.path.clone(), + index: hunk_idx, + })?; + + let mut kept_any = false; + let mut old_count = 0u32; + let mut new_count = 0u32; + let mut lines = Vec::with_capacity(hunk.lines.len()); + + for (idx, line) in hunk.lines.iter().enumerate() { + match line.kind { + LineKind::Context => { + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + LineKind::Addition => { + if sel.keep_adds.contains(&idx) { + kept_any = true; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Addition, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } else if base == PatchBase::New { + // Dropped addition, base=New: it must remain in the (already-changed) + // target, so it has to match as context on reverse-apply. + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + // base=Old: dropped addition is omitted — the target doesn't have it yet and + // shouldn't gain it. + } + LineKind::Deletion => { + if sel.keep_dels.contains(&idx) { + kept_any = true; + old_count += 1; + lines.push(PatchLine { + kind: LineKind::Deletion, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } else if base == PatchBase::Old { + // Dropped deletion, base=Old: it's still there in the target, so it has to + // match as context. + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + // base=New: dropped deletion is omitted — it's already absent from the target + // and must never be matched against. + } + } + } + + if !kept_any { + return Err(SynthesisError::EmptySelection { + path: file.path.clone(), + hunk: hunk_idx, + }); + } + + let mut header = format!( + "@@ -{},{old_count} +{},{new_count} @@", + hunk.old_start, hunk.new_start + ) + .into_bytes(); + header.extend_from_slice(&header_suffix(&hunk.header)); + + let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); + let new_path = file.path.clone(); + + Ok(PatchText { + old_path: Some(old_path), + new_path: Some(new_path), + hunks: vec![PatchHunk { + old_start: hunk.old_start, + old_count, + new_start: hunk.new_start, + new_count, + header, + lines, + }], + }) +} + #[cfg(test)] mod tests { use super::*; @@ -496,4 +677,201 @@ mod tests { assert_eq!(patch.old_path.as_deref(), Some("old.txt")); assert_eq!(patch.new_path.as_deref(), Some("f.txt")); } + + /// Two separate changes ("old2"->"new2" and "old4"->"new4") in one hunk, with a context + /// line between them — the shape `partial_hunk_patch`'s direction rules are tested against: + /// keeping only the first change should drop the second one per `base`'s rule, not just + /// omit it uniformly. + /// + /// Line indices (into `hunk.lines`): 0 ctx "line1", 1 del "old2", 2 add "new2", 3 ctx + /// "line3", 4 del "old4", 5 add "new4", 6 ctx "line5". + fn two_change_hunk() -> Hunk { + let line = |kind, content: &str, old_lnum, new_lnum| HunkLine { + kind, + content: content.as_bytes().to_vec(), + old_lnum, + new_lnum, + missing_newline: false, + }; + Hunk { + old_start: 1, + old_count: 5, + new_start: 1, + new_count: 5, + header: b"@@ -1,5 +1,5 @@\n".to_vec(), + lines: vec![ + line(LineKind::Context, "line1\n", Some(1), Some(1)), + line(LineKind::Deletion, "old2\n", Some(2), None), + line(LineKind::Addition, "new2\n", None, Some(2)), + line(LineKind::Context, "line3\n", Some(3), Some(3)), + line(LineKind::Deletion, "old4\n", Some(4), None), + line(LineKind::Addition, "new4\n", None, Some(4)), + line(LineKind::Context, "line5\n", Some(5), Some(5)), + ], + } + } + + fn keep_first_change() -> LineSelection { + LineSelection { + keep_adds: BTreeSet::from([2]), + keep_dels: BTreeSet::from([1]), + } + } + + #[test] + fn partial_base_old_omits_dropped_add_and_contexts_dropped_del() { + let file = modified_file(two_change_hunk()); + let patch = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,5 +1,5 @@\n", + " line1\n", + "-old2\n", + "+new2\n", + " line3\n", + " old4\n", + " line5\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn partial_base_new_omits_dropped_del_and_contexts_dropped_add() { + let file = modified_file(two_change_hunk()); + let patch = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::New).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,5 +1,5 @@\n", + " line1\n", + "-old2\n", + "+new2\n", + " line3\n", + " new4\n", + " line5\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn partial_recomputes_counts_when_kept_and_dropped_lines_differ() { + // Keep only the addition of the first change, dropping its deletion too (base=Old + // contexts the dropped deletion) — old_count grows relative to a hunk that dropped + // nothing, new_count reflects only the one kept addition among the two. + let file = modified_file(two_change_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([2]), + keep_dels: BTreeSet::new(), + }; + let patch = partial_hunk_patch(&file, 0, &sel, PatchBase::Old).unwrap(); + + // line1(ctx) old2(ctx, dropped del) new2(add, kept) line3(ctx) old4(ctx, dropped del) + // line5(ctx): old side never sees "new2" (5 lines), new side does (6 lines); new4 + // (dropped, unkept addition) is omitted from both. + assert_eq!(patch.hunks[0].old_count, 5); + assert_eq!(patch.hunks[0].new_count, 6); + assert_eq!(&patch.hunks[0].header[..], b"@@ -1,5 +1,6 @@\n".as_slice()); + } + + #[test] + fn partial_ignores_selection_indices_that_are_not_add_or_del() { + let file = modified_file(two_change_hunk()); + let mut sel = keep_first_change(); + // Index 0 is a context line; index 99 is out of range. Neither should change the + // rendered patch. + sel.keep_adds.insert(99); + sel.keep_dels.insert(0); + + let baseline = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old).unwrap(); + let with_junk = partial_hunk_patch(&file, 0, &sel, PatchBase::Old).unwrap(); + + assert_eq!(with_junk.to_bytes(), baseline.to_bytes()); + } + + #[test] + fn partial_empty_selection_errors() { + let file = modified_file(two_change_hunk()); + let sel = LineSelection::default(); + + assert!(matches!( + partial_hunk_patch(&file, 0, &sel, PatchBase::Old), + Err(SynthesisError::EmptySelection { .. }) + )); + } + + #[test] + fn partial_selection_naming_only_context_lines_is_effectively_empty() { + let file = modified_file(two_change_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([0, 3, 6]), // all context indices, none are additions + keep_dels: BTreeSet::new(), + }; + + assert!(matches!( + partial_hunk_patch(&file, 0, &sel, PatchBase::Old), + Err(SynthesisError::EmptySelection { .. }) + )); + } + + #[test] + fn partial_refuses_binary_file() { + let file = FileChange { + path: "bin.dat".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: true, + hunks: vec![], + }; + assert!(matches!( + partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::BinaryFile { .. }) + )); + } + + #[test] + fn partial_refuses_hunk_index_out_of_range() { + let file = modified_file(two_change_hunk()); + assert!(matches!( + partial_hunk_patch(&file, 1, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::HunkOutOfRange { .. }) + )); + } + + #[test] + fn partial_refuses_statuses_a_hunk_patch_cannot_express() { + for status in [ + FileStatus::Added, + FileStatus::Deleted, + FileStatus::Untracked, + FileStatus::Unmerged, + ] { + let file = FileChange { + path: "f.txt".to_string(), + old_path: None, + status, + is_binary: false, + hunks: vec![two_change_hunk()], + }; + assert!( + matches!( + partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::LineSelectionUnsupported { .. }) + ), + "expected refusal for status {status:?}" + ); + } + } } diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs new file mode 100644 index 00000000..760541dd --- /dev/null +++ b/git-workon-review/tests/line_synthesis.rs @@ -0,0 +1,184 @@ +//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules), run +//! against both appliers via `for_each_applier` — see `tests/apply.rs` for the pattern this +//! borrows (fresh fixture per applier backend). +//! +//! Trap-2 (the EOFNL splice) round-trips land in a follow-up commit. + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{ + Applier, ApplyDestination, ApplyDirection, CliApplier, Git2Applier, StageVerb, +}; +use workon_review::model::{FileChange, LineKind}; +use workon_review::synthesis::{partial_hunk_patch, LineSelection, PatchBase}; + +/// Run `test` once per applier backend. Each invocation gets its own closure body so callers +/// build a fresh fixture inside `test` rather than sharing one across backends (appliers mutate +/// live repository state). +fn for_each_applier(mut test: impl FnMut(&dyn Applier)) { + test(&Git2Applier); + test(&CliApplier); +} + +/// Find the `hunk.lines` index of the first line of `kind` whose content matches `content` +/// exactly — lets tests key a [`LineSelection`] off readable content instead of hard-coded +/// positions that would silently drift if git2's line ordering ever changed. +fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) -> usize { + file.hunks[hunk_idx] + .lines + .iter() + .position(|l| l.kind == kind && l.content == content.as_bytes()) + .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) +} + +/// Two separate changes ("old2"->"new2", "old4"->"new4") in one hunk, separated by a context +/// line — the shape that exercises the direction rules: keeping one change and dropping the +/// other must not affect the untouched change identically under both directions. +fn two_change_fixture() -> FixtureBuilder<'static> { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nold2\nline3\nold4\nline5\n", + "line1\nnew2\nline3\nnew4\nline5\n", + ) +} + +#[test] +fn stage_partial_updates_only_the_kept_change() { + for_each_applier(|applier| { + let fixture = two_change_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + // Only the first change landed in the index; the second change is still absent there. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nnew2\nline3\nold4\nline5\n".to_vec(), + )); + // The Index-only apply must not touch the working tree, which still carries the full + // unstaged modification (both changes). + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nnew2\nline3\nnew4\nline5\n".to_vec(), + )); + }); +} + +#[test] +fn unstage_partial_removes_only_the_kept_change_from_the_index() { + for_each_applier(|applier| { + let fixture = two_change_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + // Stage the FULL modification first (index := workdir for this path), so the staged + // (tree_to_index) model — the correct preimage for an Unstage patch (plan risk #3) — + // sees both changes. + let mut index = repo.index().expect("index"); + index + .add_path(std::path::Path::new("f.txt")) + .expect("add_path"); + index.write().expect("index write"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Unstage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + // "Kept" means "operated on": unstaging keep={old2/new2} removes just that change from + // the index, reverting it to committed content, while the second (dropped/untouched) + // change stays staged. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + }); +} + +#[test] +fn discard_partial_reverts_only_the_kept_change_in_the_workdir() { + for_each_applier(|applier| { + let fixture = two_change_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + // Nothing was ever staged in this scenario — the index still matches HEAD verbatim. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nold4\nline5\n".to_vec(), + )); + }); +} + +/// Trap-1's guard: a `base=Old` partial patch encodes a DIFFERENT, incompatible set of drop +/// rules than a `base=New` one (dropped-add-omitted/dropped-del-context vs. the mirror). Forcing +/// it through a reverse apply must fail outright rather than silently produce a wrong result — +/// this is what proves the direction rules are load-bearing, not cosmetic. +#[test] +fn base_old_partial_patch_fails_under_reverse_apply() { + let fixture = two_change_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + // Deliberately wrong: synthesize with base=Old (Stage's rules), then force a reverse apply + // (Unstage's direction) instead of a forward one. + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Reverse, + ); + + assert!( + matches!( + result, + Err(workon_review::error::ApplyError::CliApplyFailed { .. }) + ), + "expected reverse-applying a base=Old partial patch to fail, got {result:?}" + ); +} From 0449b69f6b57d77976ef92f889c5298bb8cdfece Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:47:08 -0400 Subject: [PATCH 09/18] fix(review): splice EOFNL del-to-context lines in partial patches --- git-workon-review/src/synthesis.rs | 141 ++++++++++++--- git-workon-review/tests/line_synthesis.rs | 211 +++++++++++++++++++++- 2 files changed, 317 insertions(+), 35 deletions(-) diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index df78e84b..5568076a 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -267,6 +267,68 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result) -> Vec { + let last = emitted.len().saturating_sub(1); + let mut out = Vec::with_capacity(emitted.len()); + for (i, (line, dropped_del_context)) in emitted.into_iter().enumerate() { + if dropped_del_context && line.missing_newline && i != last { + let mut readded = line.content.clone(); + readded.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content: line.content, + missing_newline: true, + }); + out.push(PatchLine { + kind: LineKind::Addition, + content: readded, + missing_newline: false, + }); + } else { + out.push(line); + } + } + out +} + /// Which of a hunk's addition/deletion lines to keep in a line-precise patch. /// /// Indices are into [`crate::model::Hunk::lines`] — the `Vec` position, NOT the old/new line @@ -320,9 +382,10 @@ pub struct LineSelection { /// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range /// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). /// -/// This function does not yet apply the trap-2 EOFNL splice (a dropped deletion converted to -/// context that carries [`crate::model::HunkLine::missing_newline`], followed by any kept -/// line, silently corrupts the blob under `git apply`) — see the follow-up commit. +/// A dropped deletion converted to context (see above) that carries +/// [`crate::model::HunkLine::missing_newline`], followed by any other emitted line, is spliced +/// by [`splice_eofnl_context_lines`] into git's canonical delete+re-add form rather than left as +/// a raw context line — see that function's docs for why (trap 2). pub fn partial_hunk_patch( file: &FileChange, hunk_idx: usize, @@ -354,38 +417,50 @@ pub fn partial_hunk_patch( let mut kept_any = false; let mut old_count = 0u32; let mut new_count = 0u32; - let mut lines = Vec::with_capacity(hunk.lines.len()); + // Each entry pairs the emitted line with whether it's a dropped-DELETION-turned-context + // line — the only shape the trap-2 splice (below) ever needs to consider — kept as one + // `push` per line so the two can't drift out of sync with each other. + let mut emitted: Vec<(PatchLine, bool)> = Vec::with_capacity(hunk.lines.len()); for (idx, line) in hunk.lines.iter().enumerate() { match line.kind { LineKind::Context => { old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } LineKind::Addition => { if sel.keep_adds.contains(&idx) { kept_any = true; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Addition, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Addition, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } else if base == PatchBase::New { // Dropped addition, base=New: it must remain in the (already-changed) // target, so it has to match as context on reverse-apply. old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } // base=Old: dropped addition is omitted — the target doesn't have it yet and // shouldn't gain it. @@ -394,21 +469,27 @@ pub fn partial_hunk_patch( if sel.keep_dels.contains(&idx) { kept_any = true; old_count += 1; - lines.push(PatchLine { - kind: LineKind::Deletion, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Deletion, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } else if base == PatchBase::Old { // Dropped deletion, base=Old: it's still there in the target, so it has to // match as context. old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + true, + )); } // base=New: dropped deletion is omitted — it's already absent from the target // and must never be matched against. @@ -423,6 +504,8 @@ pub fn partial_hunk_patch( }); } + let lines = splice_eofnl_context_lines(emitted); + let mut header = format!( "@@ -{},{old_count} +{},{new_count} @@", hunk.old_start, hunk.new_start diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs index 760541dd..216f7099 100644 --- a/git-workon-review/tests/line_synthesis.rs +++ b/git-workon-review/tests/line_synthesis.rs @@ -1,8 +1,6 @@ -//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules), run -//! against both appliers via `for_each_applier` — see `tests/apply.rs` for the pattern this -//! borrows (fresh fixture per applier backend). -//! -//! Trap-2 (the EOFNL splice) round-trips land in a follow-up commit. +//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules; trap 2: +//! the EOFNL del-to-context splice), run against both appliers via `for_each_applier` — see +//! `tests/apply.rs` for the pattern this borrows (fresh fixture per applier backend). use git_workon_fixture::prelude::*; use workon_review::acquire::diff_uncommitted; @@ -10,7 +8,9 @@ use workon_review::apply::{ Applier, ApplyDestination, ApplyDirection, CliApplier, Git2Applier, StageVerb, }; use workon_review::model::{FileChange, LineKind}; -use workon_review::synthesis::{partial_hunk_patch, LineSelection, PatchBase}; +use workon_review::synthesis::{ + partial_hunk_patch, LineSelection, PatchBase, PatchHunk, PatchLine, PatchText, +}; /// Run `test` once per applier backend. Each invocation gets its own closure body so callers /// build a fresh fixture inside `test` rather than sharing one across backends (appliers mutate @@ -182,3 +182,202 @@ fn base_old_partial_patch_fails_under_reverse_apply() { "expected reverse-applying a base=Old partial patch to fail, got {result:?}" ); } + +/// Fixture for the trap-2 (EOFNL splice) tests: the committed file's last line ("last") has NO +/// trailing newline; the modification deletes that line and adds two new ones, the last of +/// which ("more\n") DOES end in a newline (so the file gains a trailing newline overall). This +/// is the shape that produces a deletion carrying `missing_newline` with kept lines after it — +/// trap 2's precondition. +fn eofnl_fixture() -> FixtureBuilder<'static> { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nlast", "a\nb\nreplaced\nmore\n") +} + +/// Hand-built patch replicating what `partial_hunk_patch` would have produced BEFORE the trap-2 +/// splice: the dropped deletion of "last" rendered as a plain context line, still carrying its +/// `missing_newline` marker, immediately followed by the kept "+more" addition. This is a +/// test-only stand-in for the naive (pre-fix) code path — there's no live way to ask the current +/// `partial_hunk_patch` for it, since the splice isn't optional. +fn naive_unspliced_patch() -> PatchText { + PatchText { + old_path: Some("f.txt".to_string()), + new_path: Some("f.txt".to_string()), + hunks: vec![PatchHunk { + old_start: 1, + old_count: 3, + new_start: 1, + new_count: 4, + header: b"@@ -1,3 +1,4 @@\n".to_vec(), + lines: vec![ + PatchLine { + kind: LineKind::Context, + content: b"a\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Context, + content: b"b\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Context, + content: b"last".to_vec(), + missing_newline: true, + }, + PatchLine { + kind: LineKind::Addition, + content: b"more\n".to_vec(), + missing_newline: false, + }, + ], + }], + } +} + +/// THE TRIPWIRE, first: documents that `git apply` does NOT reject the naive (unspliced) form — +/// it exits 0 and silently concatenates "more" directly onto "last" with no separating newline, +/// corrupting the blob. Verified against the system `git` binary; pinned here exactly so a +/// future git version that starts rejecting (or otherwise changes) this shape is caught by a +/// test failure instead of a passing suite over a stale assumption. +#[test] +fn naive_unspliced_eofnl_patch_silently_corrupts_the_index() { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let patch = naive_unspliced_patch(); + + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_ok(), + "expected git apply to silently accept the naive form, got {result:?}" + ); + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlastmore\n".to_vec(), + )); +} + +#[test] +fn spliced_eofnl_patch_stages_correct_bytes() { + for_each_applier(|applier| { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep only "more\n"; drop the "last" deletion (context, missing_newline) and the + // "replaced\n" addition (omitted under base=Old) — trap 2's exact precondition. + let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlast\nmore\n".to_vec(), + )); + }); +} + +/// The spliced patch must still be a well-formed unified diff, not just something `git apply` +/// happens to tolerate — `git2::Diff::from_buffer` parses stricter (plan risk #4). +#[test] +fn spliced_eofnl_patch_reparses_via_git2_from_buffer() { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + git2::Diff::from_buffer(&patch.to_bytes()).expect("spliced patch reparses"); +} + +/// The splice-NOT-needed case: the dropped no-newline deletion is the LAST emitted line (nothing +/// kept comes after it), so it renders as plain context and applies cleanly with no splice. +#[test] +fn dropped_eofnl_deletion_as_last_line_needs_no_splice() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "x\ny\nlast", "X\ny\nreplacedlast") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the "x"->"X" change; drop the "last"->"replacedlast" change entirely (its + // deletion converts to context under base=Old, its addition is omitted) — the dropped + // deletion ends up as the LAST emitted line. + let keep_add = line_index(file, 0, LineKind::Addition, "X\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "x\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"X\ny\nlast".to_vec(), + )); + }); +} + +/// The base=New mirror: a dropped ADDITION carrying `missing_newline` converts to context. Per +/// [`workon_review::synthesis`]'s doc comment on `splice_eofnl_context_lines`, this can never +/// have kept lines after it — `missing_newline` is only ever set on a file's true last line, and +/// synthesis never reorders `hunk.lines` — so it needs no splice. This test is the evidence for +/// that reasoning: the committed file HAS a trailing newline, the modification drops it (the +/// addition "replaced" is the new EOF); discarding while dropping that addition must reproduce +/// the original content without corruption. +#[test] +fn dropped_eofnl_addition_as_context_needs_no_splice_under_base_new() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nlast\n", "a\nb\nreplaced") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the deletion of "last\n" (restore it on discard); drop the addition "replaced" + // (base=New converts it to context — it's the file's new EOF, so nothing follows it). + let keep_del = line_index(file, 0, LineKind::Deletion, "last\n"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"a\nb\nlast\nreplaced".to_vec(), + )); + }); +} From 81357e283c9becda3bc360cc85a46eb6cd3ab0cc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:40:14 -0400 Subject: [PATCH 10/18] fix(review): splice kept-deletion EOFNL and thread patch mode --- git-workon-review/src/synthesis.rs | 245 ++++++++++++++-------- git-workon-review/tests/line_synthesis.rs | 43 ++++ 2 files changed, 199 insertions(+), 89 deletions(-) diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 5568076a..a0f8934e 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -13,7 +13,7 @@ use std::collections::BTreeSet; use crate::error::SynthesisError; -use crate::model::{FileChange, FileStatus, LineKind}; +use crate::model::{FileChange, FileStatus, Hunk, LineKind}; /// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) /// key off this. Whole-hunk patches don't drop lines, so `PatchBase` only affects @@ -202,12 +202,13 @@ impl PatchText { } } -/// Synthesize a patch for the WHOLE of `file`'s hunk at `hunk_idx` — no line selection, so the -/// direction-dependent drop rules (trap 1) don't apply; the hunk's lines are copied verbatim. +/// Shared guard preamble for [`whole_hunk_patch`] and [`partial_hunk_patch`]: refuse binary +/// files and statuses a hunk patch can't express, look up `hunk_idx`, and derive the +/// old/new path labels — extracted so the two synthesis entry points can't drift apart on +/// these checks. /// /// Refuses: /// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. -/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). /// - statuses a hunk patch can't express ([`SynthesisError::LineSelectionUnsupported`]): /// `Added`/`Deleted`/`Untracked`/`Unmerged` are whole-file operations by nature — a hunk /// patch of a deletion would stage an empty blob instead of removing the file, and a hunk @@ -216,7 +217,11 @@ impl PatchText { /// `LineSelectionUnsupported` is the variant callers see here — it's the closest existing /// error to "use the whole-file op instead," which is exactly its `help` text. /// `Copied` is treated like `Renamed` (both carry an `old_path`). -pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result { +/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). +fn selectable_hunk( + file: &FileChange, + hunk_idx: usize, +) -> Result<(&Hunk, String, String), SynthesisError> { if file.is_binary { return Err(SynthesisError::BinaryFile { path: file.path.clone(), @@ -241,6 +246,16 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Result { + let (hunk, old_path, new_path) = selectable_hunk(file, hunk_idx)?; + let lines = hunk .lines .iter() @@ -267,9 +282,34 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Result Result) -> Vec { +/// The `KeptDeletion` fix rewrites the line's own content in place (append a real `\n`, clear +/// `missing_newline`) — no companion line, and no change to the hunk's old/new counts, since a +/// kept deletion only ever contributed to the old side either way. +/// +/// A dropped ADDITION converted to context (`base == New`) can never itself need either fix: +/// [`crate::model::HunkLine::missing_newline`] is only ever set on a line that is the true last +/// line of the file (see the module docs on the EOFNL characterization), and +/// [`partial_hunk_patch`] never reorders `hunk.lines` — so an addition carrying the flag is +/// always the LAST entry synthesized for its hunk, with nothing emitted after it to corrupt +/// against. `tests/line_synthesis.rs` has a test pinning this reasoning against a real apply +/// rather than asserting it blind. +fn splice_eofnl_context_lines(emitted: Vec<(PatchLine, SpliceNeed)>) -> Vec { + // Suffix scan: `later_has_context[i]` is true iff some `emitted[j]` with `j > i` is a + // Context line — computed once up front (over the PRE-splice kinds) so the loop below can + // ask "is anything dangerous coming after me" without repeated rescans. + let mut later_has_context = vec![false; emitted.len()]; + let mut seen_context = false; + for i in (0..emitted.len()).rev() { + later_has_context[i] = seen_context; + if emitted[i].0.kind == LineKind::Context { + seen_context = true; + } + } + let last = emitted.len().saturating_sub(1); let mut out = Vec::with_capacity(emitted.len()); - for (i, (line, dropped_del_context)) in emitted.into_iter().enumerate() { - if dropped_del_context && line.missing_newline && i != last { - let mut readded = line.content.clone(); - readded.push(b'\n'); - out.push(PatchLine { - kind: LineKind::Deletion, - content: line.content, - missing_newline: true, - }); - out.push(PatchLine { - kind: LineKind::Addition, - content: readded, - missing_newline: false, - }); - } else { - out.push(line); + for (i, (line, need)) in emitted.into_iter().enumerate() { + match need { + SpliceNeed::ConvertedContext if line.missing_newline && i != last => { + let mut readded = line.content.clone(); + readded.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content: line.content, + missing_newline: true, + }); + out.push(PatchLine { + kind: LineKind::Addition, + content: readded, + missing_newline: false, + }); + } + SpliceNeed::KeptDeletion if line.missing_newline && later_has_context[i] => { + let mut content = line.content; + content.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content, + missing_newline: false, + }); + } + _ => out.push(line), } } out @@ -382,84 +462,58 @@ pub struct LineSelection { /// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range /// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). /// -/// A dropped deletion converted to context (see above) that carries -/// [`crate::model::HunkLine::missing_newline`], followed by any other emitted line, is spliced -/// by [`splice_eofnl_context_lines`] into git's canonical delete+re-add form rather than left as -/// a raw context line — see that function's docs for why (trap 2). +/// A deletion line carrying [`crate::model::HunkLine::missing_newline`] — whether a dropped +/// deletion converted to context (see above) or a KEPT deletion emitted verbatim — followed by +/// any other emitted line, is spliced by [`splice_eofnl_context_lines`] into git's canonical +/// delete+re-add form rather than left as a raw context/deletion line — see that function's docs +/// for why (trap 2). pub fn partial_hunk_patch( file: &FileChange, hunk_idx: usize, sel: &LineSelection, base: PatchBase, ) -> Result { - if file.is_binary { - return Err(SynthesisError::BinaryFile { - path: file.path.clone(), - }); - } - match file.status { - FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} - other => { - return Err(SynthesisError::LineSelectionUnsupported { - path: file.path.clone(), - status: other, - }) - } - } - let hunk = file - .hunks - .get(hunk_idx) - .ok_or_else(|| SynthesisError::HunkOutOfRange { - path: file.path.clone(), - index: hunk_idx, - })?; + let (hunk, old_path, new_path) = selectable_hunk(file, hunk_idx)?; let mut kept_any = false; - let mut old_count = 0u32; - let mut new_count = 0u32; - // Each entry pairs the emitted line with whether it's a dropped-DELETION-turned-context - // line — the only shape the trap-2 splice (below) ever needs to consider — kept as one - // `push` per line so the two can't drift out of sync with each other. - let mut emitted: Vec<(PatchLine, bool)> = Vec::with_capacity(hunk.lines.len()); + // Each entry pairs the emitted line with which trap-2 splice check (if any) it needs — see + // [`SpliceNeed`] — kept as one `push` per line so the two can't drift out of sync with each + // other. + let mut emitted: Vec<(PatchLine, SpliceNeed)> = Vec::with_capacity(hunk.lines.len()); for (idx, line) in hunk.lines.iter().enumerate() { match line.kind { LineKind::Context => { - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } LineKind::Addition => { if sel.keep_adds.contains(&idx) { kept_any = true; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Addition, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } else if base == PatchBase::New { // Dropped addition, base=New: it must remain in the (already-changed) // target, so it has to match as context on reverse-apply. - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } // base=Old: dropped addition is omitted — the target doesn't have it yet and @@ -468,27 +522,24 @@ pub fn partial_hunk_patch( LineKind::Deletion => { if sel.keep_dels.contains(&idx) { kept_any = true; - old_count += 1; emitted.push(( PatchLine { kind: LineKind::Deletion, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::KeptDeletion, )); } else if base == PatchBase::Old { // Dropped deletion, base=Old: it's still there in the target, so it has to // match as context. - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - true, + SpliceNeed::ConvertedContext, )); } // base=New: dropped deletion is omitted — it's already absent from the target @@ -506,6 +557,19 @@ pub fn partial_hunk_patch( let lines = splice_eofnl_context_lines(emitted); + // Counts are derived from the FINAL (post-splice) lines, not accumulated during the + // selection loop above — the trap-2 splice can grow a single kept deletion into a + // deletion+addition pair, which would otherwise leave the header's `new_count` short by + // one and produce a hunk whose declared counts don't match its body. + let old_count: u32 = lines + .iter() + .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Deletion)) + .count() as u32; + let new_count: u32 = lines + .iter() + .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Addition)) + .count() as u32; + let mut header = format!( "@@ -{},{old_count} +{},{new_count} @@", hunk.old_start, hunk.new_start @@ -513,12 +577,11 @@ pub fn partial_hunk_patch( .into_bytes(); header.extend_from_slice(&header_suffix(&hunk.header)); - let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); - let new_path = file.path.clone(); - Ok(PatchText { old_path: Some(old_path), new_path: Some(new_path), + old_mode: file.old_mode, + new_mode: file.new_mode, hunks: vec![PatchHunk { old_start: hunk.old_start, old_count, @@ -916,6 +979,8 @@ mod tests { old_path: None, status: FileStatus::Modified, is_binary: true, + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![], }; assert!(matches!( @@ -946,6 +1011,8 @@ mod tests { old_path: None, status, is_binary: false, + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![two_change_hunk()], }; assert!( diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs index 216f7099..9da208e1 100644 --- a/git-workon-review/tests/line_synthesis.rs +++ b/git-workon-review/tests/line_synthesis.rs @@ -203,6 +203,8 @@ fn naive_unspliced_patch() -> PatchText { PatchText { old_path: Some("f.txt".to_string()), new_path: Some("f.txt".to_string()), + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![PatchHunk { old_start: 1, old_count: 3, @@ -381,3 +383,44 @@ fn dropped_eofnl_addition_as_context_needs_no_splice_under_base_new() { )); }); } + +/// The bug this fix targets: a KEPT deletion (not a dropped-to-context one) carrying +/// `missing_newline` under `base == New`, followed by dropped-additions-turned-context. Before +/// the fix, `partial_hunk_patch` emitted the kept deletion verbatim (never routed through the +/// trap-2 splice, which only ever looked at dropped-deletion-turned-context lines) — the CLI +/// applier silently concatenated the next line onto it (`"lastreplaced\n"`), while the git2 +/// applier rejected the patch outright (`invalid patch hunk`). Same fixture as +/// `dropped_eofnl_addition_as_context_needs_no_splice_under_base_new`'s mirror +/// (`eofnl_fixture`), but this time KEEPING the "last" deletion (instead of dropping it) so it +/// stays a `Deletion` line rather than converting to context. +#[test] +fn kept_eofnl_deletion_needs_splice_under_base_new() { + for_each_applier(|applier| { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the deletion of "last" (no trailing newline); drop both additions + // ("replaced\n"/"more\n") — base=New converts each to context, so the kept deletion is + // followed by more emitted lines. + let keep_del = line_index(file, 0, LineKind::Deletion, "last"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + // The spliced patch must still be well-formed enough for git2 to reparse (plan risk #4) + // — before the fix, git2 rejected this shape outright. + git2::Diff::from_buffer(&patch.to_bytes()).expect("spliced patch reparses"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"a\nb\nlast\nreplaced\nmore\n".to_vec(), + )); + }); +} From bbb1d817f058492a53da9cf13fa96eefb6b92150 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:58:12 -0400 Subject: [PATCH 11/18] feat(review): route whole-file ops for added and deleted files --- git-workon-fixture/src/fixture_builder.rs | 98 ++++- git-workon-fixture/tests/suite/index_state.rs | 60 +++ git-workon-review/src/file_ops.rs | 76 ++++ git-workon-review/src/lib.rs | 8 +- git-workon-review/src/ops.rs | 109 +++++ git-workon-review/tests/file_ops.rs | 410 ++++++++++++++++++ 6 files changed, 749 insertions(+), 12 deletions(-) create mode 100644 git-workon-review/src/file_ops.rs create mode 100644 git-workon-review/src/ops.rs create mode 100644 git-workon-review/tests/file_ops.rs diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index 0e9513f5..b67d6318 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -230,6 +230,8 @@ pub struct FixtureBuilder<'fixture> { untracked_files: Vec<(String, String)>, // (path, content) deleted_files: Vec<(String, String)>, // (path, committed) gh_stack_ops: Vec, + partially_staged_files: Vec<(String, String, String, String)>, // (path, committed, staged, workdir) + untracked_symlinks: Vec<(String, String)>, // (path, target) — target need not exist } impl<'fixture> FixtureBuilder<'fixture> { @@ -252,6 +254,8 @@ impl<'fixture> FixtureBuilder<'fixture> { untracked_files: Vec::new(), deleted_files: Vec::new(), gh_stack_ops: Vec::new(), + partially_staged_files: Vec::new(), + untracked_symlinks: Vec::new(), } } @@ -600,6 +604,45 @@ impl<'fixture> FixtureBuilder<'fixture> { self } + /// Commit `path` with `committed` content on the cwd repo's branch during `build()` (same + /// baseline-commit block as [`unstaged_file`](Self::unstaged_file)/[`deleted_file`](Self::deleted_file)), + /// then stage `staged` content (index entry differs from `HEAD`), then rewrite the working + /// tree copy to `workdir` (differs from BOTH `HEAD` and the index) — three genuinely + /// distinct states for `HEAD`/index/workdir, needed to test operations (like `discard`) that + /// must revert to the INDEX's content specifically, not `HEAD`'s. + /// + /// Applies to the LAST worktree added, or the main repo if none. Errors at + /// [`build`](Self::build) if the fixture is `bare(true)` with no worktree. + pub fn partially_staged_file( + mut self, + path: &str, + committed: &str, + staged: &str, + workdir: &str, + ) -> Self { + self.partially_staged_files.push(( + path.to_string(), + committed.to_string(), + staged.to_string(), + workdir.to_string(), + )); + self + } + + /// Create a symlink at `path` pointing at `target` in the fixture's cwd repo working tree; + /// never staged (untracked). `target` need not exist — a dangling/broken symlink is still a + /// real working-tree entry (`symlink_metadata`/lstat sees it; `Path::exists`, which follows + /// the link, does not). + /// + /// Unix-only ([`std::os::unix::fs::symlink`]); applies to the LAST worktree added, or the + /// main repo if none. Errors at [`build`](Self::build) if the fixture is `bare(true)` with + /// no worktree. + pub fn untracked_symlink(mut self, path: &str, target: &str) -> Self { + self.untracked_symlinks + .push((path.to_string(), target.to_string())); + self + } + pub fn build(self) -> Result { isolate_ambient_git_config(); let tmpdir = TempDir::new()?; @@ -707,20 +750,25 @@ impl<'fixture> FixtureBuilder<'fixture> { let has_index_state = !self.staged_files.is_empty() || !self.unstaged_files.is_empty() || !self.untracked_files.is_empty() - || !self.deleted_files.is_empty(); + || !self.deleted_files.is_empty() + || !self.partially_staged_files.is_empty() + || !self.untracked_symlinks.is_empty(); if has_index_state && self.bare && self.worktrees.is_empty() { return Err( - "staged_file/unstaged_file/untracked_file/deleted_file require a working tree: \ - fixture is bare(true) with no worktree" + "staged_file/unstaged_file/untracked_file/deleted_file/partially_staged_file/\ + untracked_symlink require a working tree: fixture is bare(true) with no worktree" .into(), ); } - // `unstaged_file`/`deleted_file` baseline commits land BEFORE Graphite-metadata - // live-tip resolution below: they move the cwd branch's tip, and any metadata entry - // recording that tip must reflect the moved one, not the pre-baseline commit. Both - // builders share one baseline commit. - if !self.unstaged_files.is_empty() || !self.deleted_files.is_empty() { + // `unstaged_file`/`deleted_file`/`partially_staged_file` baseline commits land BEFORE + // Graphite-metadata live-tip resolution below: they move the cwd branch's tip, and any + // metadata entry recording that tip must reflect the moved one, not the pre-baseline + // commit. All three builders share one baseline commit. + if !self.unstaged_files.is_empty() + || !self.deleted_files.is_empty() + || !self.partially_staged_files.is_empty() + { let cwd_repo = Repository::open(&cwd_path)?; let mut index = cwd_repo.index()?; for (file_path, committed, _modified) in &self.unstaged_files { @@ -739,6 +787,14 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, committed)?; index.add_path(Path::new(file_path))?; } + for (file_path, committed, _staged, _workdir) in &self.partially_staged_files { + let abs_path = cwd_path.join(file_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&abs_path, committed)?; + index.add_path(Path::new(file_path))?; + } index.write()?; let tree_id = index.write_tree()?; @@ -1125,7 +1181,7 @@ impl<'fixture> FixtureBuilder<'fixture> { if has_index_state { let cwd_repo = Repository::open(&cwd_path)?; - if !self.staged_files.is_empty() { + if !self.staged_files.is_empty() || !self.partially_staged_files.is_empty() { let mut index = cwd_repo.index()?; for (file_path, content) in &self.staged_files { let abs_path = cwd_path.join(file_path); @@ -1135,6 +1191,11 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, content)?; index.add_path(Path::new(file_path))?; } + for (file_path, _committed, staged, _workdir) in &self.partially_staged_files { + let abs_path = cwd_path.join(file_path); + std::fs::write(&abs_path, staged)?; + index.add_path(Path::new(file_path))?; + } index.write()?; } @@ -1142,6 +1203,12 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(cwd_path.join(file_path), modified)?; } + // Rewrite the working tree copy to `workdir` content AFTER the index has `staged` + // — the index entry must stay at `staged`, only the on-disk file moves further. + for (file_path, _committed, _staged, workdir) in &self.partially_staged_files { + std::fs::write(cwd_path.join(file_path), workdir)?; + } + for (file_path, content) in &self.untracked_files { let abs_path = cwd_path.join(file_path); if let Some(parent) = abs_path.parent() { @@ -1153,6 +1220,19 @@ impl<'fixture> FixtureBuilder<'fixture> { for (file_path, _committed) in &self.deleted_files { std::fs::remove_file(cwd_path.join(file_path))?; } + + #[cfg(unix)] + for (file_path, target) in &self.untracked_symlinks { + let abs_path = cwd_path.join(file_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::os::unix::fs::symlink(target, &abs_path)?; + } + #[cfg(not(unix))] + if !self.untracked_symlinks.is_empty() { + return Err("untracked_symlink is unix-only".into()); + } } if self.worktrees.is_empty() { diff --git a/git-workon-fixture/tests/suite/index_state.rs b/git-workon-fixture/tests/suite/index_state.rs index ca34af6c..95330f25 100644 --- a/git-workon-fixture/tests/suite/index_state.rs +++ b/git-workon-fixture/tests/suite/index_state.rs @@ -341,3 +341,63 @@ fn deleted_file_baseline_commit_lands_before_metadata_resolution( Ok(()) } + +#[test] +fn partially_staged_file_has_three_distinct_states() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .partially_staged_file("f.txt", "committed", "staged", "workdir") + .build()?; + + let repo = fixture.repo()?; + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + b"staged".to_vec(), + )); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"workdir".to_vec(), + )); + + let head_commit = repo.head()?.peel_to_commit()?; + let tree = head_commit.tree()?; + let entry = tree.get_path(std::path::Path::new("f.txt"))?; + let blob = repo.find_blob(entry.id())?; + assert_eq!(blob.content(), b"committed"); + + Ok(()) +} + +#[test] +fn partially_staged_file_bare_with_no_worktree_errors() { + let result = FixtureBuilder::new() + .bare(true) + .partially_staged_file("f.txt", "committed", "staged", "workdir") + .build(); + + assert!( + result.is_err(), + "bare fixture with no worktree has no working tree to partially stage into" + ); +} + +#[cfg(unix)] +#[test] +fn untracked_symlink_is_visible_via_lstat_even_when_dangling( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .untracked_symlink("broken-link", "nonexistent-target") + .build()?; + + let dir = fixture.cwd()?; + let link_path = dir.path().join("broken-link"); + assert!( + link_path.symlink_metadata().is_ok(), + "lstat should see the dangling symlink" + ); + assert!( + !link_path.exists(), + "Path::exists follows the link and should report false for a dangling target" + ); + + Ok(()) +} diff --git a/git-workon-review/src/file_ops.rs b/git-workon-review/src/file_ops.rs new file mode 100644 index 00000000..0f7ef370 --- /dev/null +++ b/git-workon-review/src/file_ops.rs @@ -0,0 +1,76 @@ +//! Whole-file operations (trap 3): the staging verbs a hunk patch cannot express, because a +//! hunk patch always has BOTH a pre-image and a post-image to diff between. Creations, +//! deletions, and untracked files each have only one side — synthesizing a hunk patch for them +//! either has no preimage to apply against (untracked: git rejects it) or stages an EMPTY BLOB +//! instead of removing the file (deleted: git happily accepts a patch that deletes every line +//! of a tracked file, but that isn't the same operation as removing the index entry). `ops.rs` +//! routes those statuses here instead of through `synthesis`/`apply`. + +use std::path::Path; + +use git2::Repository; + +use crate::error::ApplyError; + +/// Stage `path`: `index.add_path` when the working-tree copy exists (covers Added, Untracked, +/// and Modified — an ordinary content update), `index.remove_path` when it doesn't (a real +/// deletion: `git rm`'s effect, not a hunk patch that would stage an empty blob). +/// +/// The choice is made by checking the working tree on disk, not by trusting a `FileStatus` +/// passed in by the caller — the two can only usefully agree once the check runs, so the check +/// is the source of truth (trap 3's core fix). +/// +/// The presence check uses `symlink_metadata` (lstat), NOT `Path::exists` (which follows +/// symlinks and reports `false` for a broken one). An untracked BROKEN symlink is still a real +/// working-tree entry that `git add` stages (as the link text, like any other symlink) — +/// `Path::exists` would silently take the `remove_path` branch instead, a no-op that leaves the +/// symlink unstaged with no error. +pub fn stage_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("stage_file requires a repository with a working directory"); + let mut index = repo.index()?; + if workdir.join(path).symlink_metadata().is_ok() { + index.add_path(Path::new(path))?; + } else { + index.remove_path(Path::new(path))?; + } + index.write()?; + Ok(()) +} + +/// Unstage `path`: reset its index entry back to `HEAD`. `reset_default` handles the +/// staged-new-file case natively — when `path` has no `HEAD` entry, the index entry is removed +/// outright and the file becomes untracked again, exactly like `git reset HEAD -- path` on a +/// newly-added file. +pub fn unstage_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let head = repo.head()?.peel(git2::ObjectType::Commit)?; + repo.reset_default(Some(&head), [path])?; + Ok(()) +} + +/// Discard `path`'s working-tree changes: check out the INDEX'S copy over the working tree +/// copy, without touching the index (`update_index(false)` — a discard must not also stage +/// anything). This is `git restore `'s effect, NOT `git checkout HEAD -- `'s: on a +/// partially staged file (`HEAD` = A, index = B, workdir = C), discarding must revert the +/// workdir to what's staged (B), not blow past it to HEAD (A) and silently wipe the staged +/// work. `checkout_head` restores from `HEAD` and was wrong for exactly this reason — it's only +/// correct by coincidence when nothing is staged (index == HEAD). +pub fn discard_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.path(path).force().update_index(false); + repo.checkout_index(None, Some(&mut opts))?; + Ok(()) +} + +/// Discard an untracked file: there is no `HEAD`/index copy to check out, so "discard" means +/// deleting the working-tree file outright. +pub fn clean_untracked(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("clean_untracked requires a repository with a working directory"); + std::fs::remove_file(workdir.join(path)).map_err(|source| ApplyError::Io { + path: path.to_string(), + source, + }) +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 1a402b33..70ec4da2 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -7,12 +7,14 @@ //! ## Status //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s -//! ([`acquire`]), whole-hunk patch synthesis ([`synthesis`]), and the apply chokepoint -//! ([`apply`]) exist; line-precise synthesis, file ops, staging, and refresh land in later M2 -//! changesets. +//! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file +//! ops ([`file_ops`]), and the patch-vs-file-op routing layer ([`ops`]) exist; staging and +//! refresh land in later M2 changesets. pub mod acquire; pub mod apply; pub mod error; +pub mod file_ops; pub mod model; +pub mod ops; pub mod synthesis; diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs new file mode 100644 index 00000000..60d952e8 --- /dev/null +++ b/git-workon-review/src/ops.rs @@ -0,0 +1,109 @@ +//! Routing: the ONE place (per the M2 design decision) that decides, for a given +//! [`FileChange`], whether a staging verb goes through the patch-synthesis-and-apply path +//! (`synthesis.rs`/`apply.rs`) or the whole-file path (`file_ops.rs`). The TUI (M4) calls only +//! these three functions — it never picks a path itself. +//! +//! ## The routing table (trap 3) +//! +//! - [`FileStatus::Modified`]/[`FileStatus::Renamed`]/[`FileStatus::Copied`], non-binary: a +//! hunk patch can express both a preimage and a postimage, so `apply_hunk`/`apply_lines` +//! synthesize one and hand it to the `Applier`. +//! - Everything else ([`FileStatus::Added`]/[`FileStatus::Deleted`]/[`FileStatus::Untracked`]/ +//! [`FileStatus::Unmerged`], or a binary file of any status): there is no two-sided hunk to +//! patch — a hunk of one of these files IS the whole file. `apply_hunk` falls back to the +//! file-level op for the verb. `apply_lines` does NOT fall back: line selection on a +//! whole-file change is a different operation the caller asked for by mistake, so it must +//! REFUSE with a typed error rather than silently widen the selection to "the whole file" +//! behind the caller's back. The cleanest way to get that refusal is to call +//! `partial_hunk_patch` unconditionally and propagate its `Result` — it already contains +//! exactly this guard (see `synthesis.rs`), so `apply_lines` doesn't duplicate the status +//! check. + +use git2::Repository; + +use crate::apply::{Applier, StageVerb}; +use crate::error::ReviewError; +use crate::file_ops; +use crate::model::{FileChange, FileStatus}; +use crate::synthesis::{partial_hunk_patch, whole_hunk_patch, LineSelection}; + +/// Whether `file`'s status/binary-ness can be expressed as a two-sided hunk patch (Modified, +/// Renamed, or Copied, and not binary) — the routing predicate shared by `apply_hunk` and the +/// doc comments above. +fn is_hunk_patchable(file: &FileChange) -> bool { + !file.is_binary + && matches!( + file.status, + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied + ) +} + +/// Apply `verb` to the WHOLE of `file`'s hunk at `hunk_idx`. +/// +/// Routes through patch synthesis when the file is hunk-patchable; otherwise falls back to the +/// file-level op for `verb` — a hunk of an Added/Deleted/Untracked/Unmerged or binary file IS +/// the whole file, so there's nothing hunk-specific left to do. +pub fn apply_hunk( + repo: &Repository, + applier: &dyn Applier, + file: &FileChange, + hunk_idx: usize, + verb: StageVerb, +) -> Result<(), ReviewError> { + if is_hunk_patchable(file) { + let patch = whole_hunk_patch(file, hunk_idx)?; + let (_, dest, dir) = verb.plan(); + applier.apply(repo, &patch, dest, dir)?; + Ok(()) + } else { + apply_file(repo, file, verb) + } +} + +/// Apply `verb` to a line-precise selection of `file`'s hunk at `hunk_idx`. +/// +/// Unlike `apply_hunk`, this never falls back to a file-level op: line selection on a status a +/// hunk patch can't express (or a binary file) is a REFUSAL (trap 3), not a silent widening to +/// "the whole file." `partial_hunk_patch` already carries that refusal +/// ([`crate::error::SynthesisError::LineSelectionUnsupported`] / +/// [`crate::error::SynthesisError::BinaryFile`]), so calling it unconditionally and propagating +/// its `Result` is both the simplest routing and the correct one. +pub fn apply_lines( + repo: &Repository, + applier: &dyn Applier, + file: &FileChange, + hunk_idx: usize, + sel: &LineSelection, + verb: StageVerb, +) -> Result<(), ReviewError> { + let (base, dest, dir) = verb.plan(); + let patch = partial_hunk_patch(file, hunk_idx, sel, base)?; + applier.apply(repo, &patch, dest, dir)?; + Ok(()) +} + +/// Apply `verb` to the WHOLE of `file`, unconditionally via `file_ops.rs` — no synthesis +/// involved. This is also `apply_hunk`'s fallback for statuses a hunk patch can't express. +/// +/// `Discard` on an [`FileStatus::Untracked`] file is the one verb/status pair with no `HEAD` +/// copy to check out: [`file_ops::discard_file`]'s `checkout_head` has nothing to restore, so +/// "discard" instead means deleting the working-tree file outright +/// ([`file_ops::clean_untracked`]). +pub fn apply_file( + repo: &Repository, + file: &FileChange, + verb: StageVerb, +) -> Result<(), ReviewError> { + match verb { + StageVerb::Stage => file_ops::stage_file(repo, &file.path)?, + StageVerb::Unstage => file_ops::unstage_file(repo, &file.path)?, + StageVerb::Discard => { + if file.status == FileStatus::Untracked { + file_ops::clean_untracked(repo, &file.path)? + } else { + file_ops::discard_file(repo, &file.path)? + } + } + } + Ok(()) +} diff --git a/git-workon-review/tests/file_ops.rs b/git-workon-review/tests/file_ops.rs new file mode 100644 index 00000000..152c5a84 --- /dev/null +++ b/git-workon-review/tests/file_ops.rs @@ -0,0 +1,410 @@ +//! Trap 3 (whole-file ops): tripwires proving the naive hunk-patch shapes for +//! deletion/untracked files misbehave (empty-blob-stage / rejection), then the routed +//! `ops.rs`/`file_ops.rs` behavior that exists to route around them. +//! +//! Fixtures pin `core.autocrlf=false` so index/workdir byte assertions are platform-stable +//! (plan risk #6). + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{Applier, ApplyDestination, ApplyDirection, CliApplier, StageVerb}; +use workon_review::error::{ReviewError, SynthesisError}; +use workon_review::model::LineKind; +use workon_review::ops::{apply_file, apply_hunk, apply_lines}; +use workon_review::synthesis::{LineSelection, PatchHunk, PatchLine, PatchText}; + +/// Hand-build the patch a naive whole-hunk stage of a DELETION would render: a hunk deleting +/// every line, `--- a/` / `+++ b/` (not `/dev/null` — the file still exists at +/// `path` in the index/HEAD, only its content is fully removed). This is what +/// `whole_hunk_patch` would produce if it didn't refuse `FileStatus::Deleted` — there's no live +/// way to ask the real synthesis path for it, so it's reconstructed by hand, mirroring +/// `tests/line_synthesis.rs`'s `naive_unspliced_patch` pattern. +fn naive_deletion_hunk_patch(path: &str, committed_content: &str) -> PatchText { + let lines: Vec = committed_content + .lines() + .map(|line| PatchLine { + kind: LineKind::Deletion, + content: format!("{line}\n").into_bytes(), + missing_newline: false, + }) + .collect(); + let count = lines.len() as u32; + PatchText { + old_path: Some(path.to_string()), + new_path: Some(path.to_string()), + old_mode: 0o100644, + new_mode: 0o100644, + hunks: vec![PatchHunk { + old_start: 1, + old_count: count, + new_start: 0, + new_count: 0, + header: format!("@@ -1,{count} +0,0 @@\n").into_bytes(), + lines, + }], + } +} + +/// Hand-build the patch a naive whole-hunk stage of an UNTRACKED file would render: an +/// all-additions hunk from `/dev/null` to `b/` — what `whole_hunk_patch` would produce if +/// it didn't refuse `FileStatus::Untracked`. +fn naive_untracked_hunk_patch(path: &str, content: &str) -> PatchText { + let lines: Vec = content + .lines() + .map(|line| PatchLine { + kind: LineKind::Addition, + content: format!("{line}\n").into_bytes(), + missing_newline: false, + }) + .collect(); + let count = lines.len() as u32; + PatchText { + old_path: None, + new_path: Some(path.to_string()), + old_mode: 0o100644, + new_mode: 0o100644, + hunks: vec![PatchHunk { + old_start: 0, + old_count: 0, + new_start: 1, + new_count: count, + header: format!("@@ -0,0 +1,{count} @@\n").into_bytes(), + lines, + }], + } +} + +/// TRIPWIRE: a naive whole-hunk stage of a deletion (deleting every line, but keeping the +/// `a/`/`b/` paths as if the file still existed) is ACCEPTED by `git apply --cached` — it +/// stages an EMPTY BLOB for the path instead of removing the index entry. This is exactly the +/// bug `ops.rs`'s routing to `file_ops::stage_file` exists to prevent (trap 3). Verified +/// directly against `CliApplier` (the oracle), bypassing `ops.rs`/`synthesis.rs` entirely, +/// since `whole_hunk_patch` already refuses `FileStatus::Deleted` and can't produce this patch +/// itself. +#[test] +fn naive_hunk_stage_of_deletion_stages_empty_blob() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let patch = naive_deletion_hunk_patch("gone.txt", "content\n"); + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_ok(), + "expected git apply --cached to accept the naive deletion hunk, got {result:?}" + ); + fixture.assert(predicate::repo::index_blob_equals("gone.txt", b"".to_vec())); +} + +/// TRIPWIRE: a naive whole-hunk stage of an untracked file (from `/dev/null`) is REJECTED by +/// `git apply --cached` — the file isn't in the index yet, so there's no preimage to apply the +/// patch's context against ("... does not exist in index"). Verified directly against +/// `CliApplier`, bypassing `ops.rs`/`synthesis.rs` for the same reason as the deletion +/// tripwire above. +#[test] +fn naive_hunk_stage_of_untracked_errors() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let patch = naive_untracked_hunk_patch("new.txt", "hello\n"); + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_err(), + "expected git apply --cached to reject the naive untracked hunk, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_deleted_file_refuses() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_untracked_file_refuses() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_added_file_refuses() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_file_stage_on_deleted_file_stages_the_deletion() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage).expect("apply_file"); + + fixture.assert(predicate::repo::has_staged_deletion("gone.txt")); +} + +#[test] +fn apply_file_stage_on_untracked_file_stages_its_content() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage).expect("apply_file"); + + fixture.assert(predicate::repo::has_staged_file("new.txt")); + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\n".to_vec(), + )); +} + +#[test] +fn apply_file_discard_on_untracked_file_removes_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard).expect("apply_file"); + + assert!(!repo.workdir().unwrap().join("new.txt").exists()); +} + +#[test] +fn apply_file_unstage_on_staged_new_file_becomes_untracked_again() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + apply_file(repo, file, StageVerb::Unstage).expect("apply_file"); + + fixture.assert(predicate::repo::has_untracked_file("added.txt")); + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index + .get_path(std::path::Path::new("added.txt"), 0) + .is_none(), + "expected no index entry for added.txt after unstage" + ); +} + +#[test] +fn apply_file_discard_on_tracked_modified_file_reverts_content() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard).expect("apply_file"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); +} + +#[test] +fn apply_hunk_on_binary_modified_file_routes_to_file_level_stage() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build() + .expect("fixture build"); + + fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 1, 2, 3, b'a', 0u8]) + .create("add binary") + .expect("commit binary"); + + let repo = fixture.repo().expect("repo"); + let new_bytes = vec![0u8, 9, 9, 9, b'z', 0u8]; + std::fs::write(repo.workdir().unwrap().join("bin.dat"), &new_bytes) + .expect("overwrite binary file"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + assert!(file.is_binary, "expected the modified file to be binary"); + + apply_hunk(repo, &CliApplier, file, 0, StageVerb::Stage).expect("apply_hunk"); + + fixture.assert(predicate::repo::index_blob_equals("bin.dat", new_bytes)); +} + +/// Regression for the `discard_file` bug: on a PARTIALLY staged file (`HEAD` = "committed", +/// index = "staged", workdir = "workdir" — three distinct states), discarding must revert the +/// workdir to the INDEX's content ("staged"), matching `git restore ` — NOT blow past it +/// to `HEAD`'s content ("committed"), which is what the old `checkout_head`-based +/// implementation did, silently wiping staged work off disk. +#[test] +fn apply_file_discard_on_partially_staged_file_reverts_to_index_not_head() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("f.txt", "committed\n", "staged\n", "workdir\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard).expect("apply_file"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"staged\n".to_vec(), + )); + // The index itself must be untouched by a discard. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"staged\n".to_vec(), + )); +} + +/// Regression for the `stage_file` bug: an untracked BROKEN symlink is a real working-tree +/// entry (`git add` stages it, storing the link text as the blob, exactly like any other +/// symlink) — but the old `Path::exists()` check follows the link, sees nothing at the +/// (nonexistent) target, and silently takes the `remove_path` branch instead: a no-op that +/// returns `Ok` without staging anything. +#[cfg(unix)] +#[test] +fn apply_file_stage_on_untracked_broken_symlink_stages_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_symlink("broken-link", "nonexistent-target") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage).expect("apply_file"); + + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index + .get_path(std::path::Path::new("broken-link"), 0) + .is_some(), + "expected an index entry for the staged broken symlink" + ); +} + +#[test] +fn apply_hunk_on_modified_text_file_passes_through_to_whole_hunk_stage() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, &CliApplier, file, 0, StageVerb::Stage).expect("apply_hunk"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); +} From 3d96611011241c26f51f6b6662a4eb15ae995f8e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:10:46 -0400 Subject: [PATCH 12/18] feat(review): add FIFO staging queue with live-index direction --- git-workon-review/src/lib.rs | 6 +- git-workon-review/src/queue.rs | 509 +++++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/queue.rs diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 70ec4da2..4ecf8118 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -8,8 +8,9 @@ //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s //! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file -//! ops ([`file_ops`]), and the patch-vs-file-op routing layer ([`ops`]) exist; staging and -//! refresh land in later M2 changesets. +//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), and the FIFO staging queue +//! ([`queue`]) exist; the refresh coordinator and the round-trip verdict corpus land in later +//! M2 changesets. pub mod acquire; pub mod apply; @@ -17,4 +18,5 @@ pub mod error; pub mod file_ops; pub mod model; pub mod ops; +pub mod queue; pub mod synthesis; diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs new file mode 100644 index 00000000..900eb7ec --- /dev/null +++ b/git-workon-review/src/queue.rs @@ -0,0 +1,509 @@ +//! FIFO staging queue (trap 4): callers enqueue [`StagingOp`]s, [`StagingQueue::pump`] runs the +//! head op synchronously against the live index. Runtime-agnostic per the M2 design decision — +//! no tokio, no owned thread; the caller (M4's TUI event loop) decides when to pump. +//! +//! ## The stale-snapshot trap +//! +//! An op MUST resolve its direction (stage vs. unstage, etc.) by querying the LIVE index +//! INSIDE `run` — via [`path_has_staged_changes`] or equivalent — never from a snapshot taken +//! at enqueue time. Two ops enqueued back-to-back for the same path (e.g. a user double-toggling +//! a file before the first op has run) both see whatever the index looks like when THEY run, not +//! when they were queued; a snapshot-at-enqueue implementation would have both ops decide the +//! same direction and the second would silently no-op instead of round-tripping the toggle. +//! +//! ## In-flight accounting +//! +//! [`OpContext::queue_len`] includes the op currently running — `pump` computes it from the +//! queue BEFORE popping the head op, and only removes the op once `run` returns (success, +//! failure, or panic). This lets an op (or a refresh gate reading [`StagingQueue::len`]) see +//! "at least one more op is in flight" for its own duration, not just for the ops still waiting +//! behind it. See `refresh.rs` for the consumer: `RefreshCoordinator::note_index_event` refuses +//! to schedule a refresh while this count is nonzero. + +use std::collections::VecDeque; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::time::Duration; + +use git2::{Repository, StatusOptions}; + +use crate::apply::Applier; +use crate::error::ApplyError; + +/// Identifies a queued operation, assigned in enqueue order. +pub type OpId = u64; + +/// What a running [`StagingOp`] needs to do its work and to make its own live-index checks. +pub struct OpContext<'a> { + pub repo: &'a Repository, + pub applier: &'a dyn Applier, + /// Number of ops in the queue, INCLUDING the one currently running. + pub queue_len: usize, +} + +/// Whether `path` currently has any staged (index-side) changes, resolved against the LIVE +/// index at call time — the direction-resolution primitive ops must call from inside `run` +/// (never at enqueue time; see the module doc's stale-snapshot trap). +pub fn path_has_staged_changes(repo: &Repository, path: &str) -> Result { + let mut opts = StatusOptions::new(); + opts.pathspec(path); + opts.include_untracked(true); + let statuses = repo.statuses(Some(&mut opts))?; + let index_bits = git2::Status::INDEX_NEW + | git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_DELETED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE; + Ok(statuses + .iter() + .any(|entry| entry.status().intersects(index_bits))) +} + +/// A single queued staging action. Implementations resolve their own direction from the live +/// index inside `run` (see module docs) rather than trusting anything decided at enqueue time. +pub trait StagingOp: Send { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError>; +} + +/// The result of running one queued op. +#[derive(Debug)] +pub enum OpOutcome { + Completed(OpId), + Failed(OpId, ApplyError), + Panicked(OpId), +} + +/// Injectable sleep for retry backoff — a type alias keeps `StagingQueue`'s field type simple +/// enough for clippy's `type_complexity` lint (a bare `Box` field would +/// otherwise read as a false positive candidate once combined with the rest of the struct). +type SleepFn = Box; + +/// Runtime-agnostic FIFO queue of staging operations (trap 4). Only the head op ever runs; +/// [`StagingQueue::pump`] runs it synchronously to completion (including its one retry, if +/// index-lock contention is hit) before removing it. +pub struct StagingQueue { + queue: VecDeque<(OpId, Box)>, + next_id: OpId, + retry_delay: Duration, + sleep: SleepFn, +} + +impl Default for StagingQueue { + fn default() -> Self { + Self::new() + } +} + +impl StagingQueue { + /// A queue with the real 100ms retry delay and `std::thread::sleep`. + pub fn new() -> Self { + Self::with_retry(Duration::from_millis(100), std::thread::sleep) + } + + /// A queue with an injectable retry delay and sleep function — tests pass `Duration::ZERO` + /// and a counting closure so the retry-once policy can be asserted without actually + /// sleeping. + pub fn with_retry(delay: Duration, sleep: impl FnMut(Duration) + 'static) -> Self { + Self { + queue: VecDeque::new(), + next_id: 0, + retry_delay: delay, + sleep: Box::new(sleep), + } + } + + /// Enqueue `op` at the tail, returning its assigned id. + pub fn enqueue(&mut self, op: impl StagingOp + 'static) -> OpId { + let id = self.next_id; + self.next_id += 1; + self.queue.push_back((id, Box::new(op))); + id + } + + /// Run the head op synchronously against `repo`/`applier`. Returns `None` if the queue is + /// empty. + /// + /// The op is removed from the queue only AFTER it finishes — `queue_len` is computed from + /// the queue's current length (including the head op itself) before the op runs, so an op + /// can see "how many ops, including me, are outstanding" via [`OpContext::queue_len`]. + /// + /// On a lock-contention error ([`crate::apply::is_lock_contention`]), the op is retried + /// exactly once after `sleep(retry_delay)`; a second lock failure yields + /// `Failed(id, ApplyError::IndexLocked { attempts: 2 })` rather than propagating the + /// original error, since by that point the queue has given up and the notable fact is the + /// retry count, not which particular lock error surfaced. Non-lock errors fail immediately, + /// no retry. + pub fn pump(&mut self, repo: &Repository, applier: &dyn Applier) -> Option { + let queue_len = self.queue.len(); + let (id, mut op) = self.queue.pop_front()?; + + let ctx = OpContext { + repo, + applier, + queue_len, + }; + + // SAFETY/soundness note (plan risk #9): `&Repository` isn't `UnwindSafe`, so `run` + // can't be called under `catch_unwind` without asserting it. This is sound because the + // op (and the `ctx` borrowing `repo`) is discarded immediately after a panic is caught + // — nothing observes `op`'s or `repo`'s state through a broken invariant afterward; the + // queue only ever looks at its own `VecDeque`, which is untouched by the panic. + let result = catch_unwind(AssertUnwindSafe(|| op.run(&ctx))); + + let outcome = match result { + Ok(Ok(())) => OpOutcome::Completed(id), + Ok(Err(e)) if crate::apply::is_lock_contention(&e) => { + (self.sleep)(self.retry_delay); + let retry_ctx = OpContext { + repo, + applier, + queue_len, + }; + match catch_unwind(AssertUnwindSafe(|| op.run(&retry_ctx))) { + Ok(Ok(())) => OpOutcome::Completed(id), + Ok(Err(_)) => OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }), + Err(_) => OpOutcome::Panicked(id), + } + } + Ok(Err(e)) => OpOutcome::Failed(id, e), + Err(_) => OpOutcome::Panicked(id), + }; + + Some(outcome) + } + + /// Pump until the queue is empty, collecting each op's outcome in order. + pub fn drain(&mut self, repo: &Repository, applier: &dyn Applier) -> Vec { + let mut outcomes = Vec::new(); + while let Some(outcome) = self.pump(repo, applier) { + outcomes.push(outcome); + } + outcomes + } + + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use git_workon_fixture::prelude::*; + + use super::*; + use crate::apply::CliApplier; + use crate::file_ops::unstage_file; + + /// A fake op that records its id into a shared log on run and always succeeds. Uses + /// `Arc>` (not `Rc>`) because [`StagingOp`] requires `Send`. + struct RecordingOp { + id_slot: OpId, + log: Arc>>, + } + + impl StagingOp for RecordingOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.log.lock().unwrap().push(self.id_slot); + Ok(()) + } + } + + /// An op that asserts `ctx.queue_len` equals an expected value the first time it runs. + struct AssertQueueLenOp { + expected: usize, + seen: Arc>>, + } + + impl StagingOp for AssertQueueLenOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + *self.seen.lock().unwrap() = Some(ctx.queue_len); + assert_eq!( + ctx.queue_len, self.expected, + "op did not see itself counted in queue_len while running" + ); + Ok(()) + } + } + + fn lock_error() -> ApplyError { + ApplyError::Git(git2::Error::new( + git2::ErrorCode::Locked, + git2::ErrorClass::Index, + "locked", + )) + } + + /// A fake op that fails with a lock error for its first `attempts_to_fail` calls, then + /// succeeds. + struct FlakyLockOp { + attempts_to_fail: u32, + calls: u32, + } + + impl StagingOp for FlakyLockOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.calls += 1; + if self.calls <= self.attempts_to_fail { + Err(lock_error()) + } else { + Ok(()) + } + } + } + + /// A fake op that always fails with a non-lock error. + struct AlwaysNonLockErrorOp; + + impl StagingOp for AlwaysNonLockErrorOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + Err(ApplyError::GitSpawn(std::io::Error::other("boom"))) + } + } + + /// An op whose `run` panics unconditionally. + struct PanickingOp; + + impl StagingOp for PanickingOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + panic!("staging op exploded"); + } + } + + /// Toggles staged/unstaged state of `path` by resolving direction from the LIVE index + /// inside `run` — proves ops must not cache the direction at enqueue time (trap 4's + /// stale-snapshot bug). + struct ToggleOp { + path: &'static str, + } + + impl StagingOp for ToggleOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + if path_has_staged_changes(ctx.repo, self.path)? { + unstage_file(ctx.repo, self.path) + } else { + let mut index = ctx.repo.index()?; + index.add_path(std::path::Path::new(self.path))?; + index.write()?; + Ok(()) + } + } + } + + fn fresh_queue() -> (StagingQueue, Arc>) { + let sleep_calls = Arc::new(Mutex::new(0u32)); + let counter = Arc::clone(&sleep_calls); + let queue = StagingQueue::with_retry(Duration::ZERO, move |_| { + *counter.lock().unwrap() += 1; + }); + (queue, sleep_calls) + } + + #[test] + fn fifo_order_preserved_through_drain() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let log = Arc::new(Mutex::new(Vec::new())); + let (mut queue, _sleep_calls) = fresh_queue(); + let a = queue.enqueue(RecordingOp { + id_slot: 0, + log: Arc::clone(&log), + }); + let b = queue.enqueue(RecordingOp { + id_slot: 1, + log: Arc::clone(&log), + }); + let c = queue.enqueue(RecordingOp { + id_slot: 2, + log: Arc::clone(&log), + }); + + let outcomes = queue.drain(repo, &CliApplier); + + assert_eq!(*log.lock().unwrap(), vec![a, b, c]); + assert!(matches!(outcomes[0], OpOutcome::Completed(id) if id == a)); + assert!(matches!(outcomes[1], OpOutcome::Completed(id) if id == b)); + assert!(matches!(outcomes[2], OpOutcome::Completed(id) if id == c)); + } + + #[test] + fn head_op_sees_itself_counted_in_queue_len() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let seen = Arc::new(Mutex::new(None)); + let (mut queue, _sleep_calls) = fresh_queue(); + // Two ops queued: the head op should see queue_len == 2 (itself + the one behind it). + queue.enqueue(AssertQueueLenOp { + expected: 2, + seen: Arc::clone(&seen), + }); + queue.enqueue(RecordingOp { + id_slot: 99, + log: Arc::new(Mutex::new(Vec::new())), + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(outcome, OpOutcome::Completed(_))); + assert_eq!(*seen.lock().unwrap(), Some(2)); + } + + /// Two toggles queued for the same path, starting unstaged: if an implementation resolved + /// "stage" once at enqueue time and reused it, both ops would stage, leaving the file + /// staged. Because `ToggleOp::run` calls `path_has_staged_changes` against the LIVE index + /// each time it actually runs, the first toggle stages it and the second (seeing the + /// now-staged index) unstages it back — the file round-trips to unstaged, exactly the + /// trap-4 stale-snapshot bug this test guards against. + #[test] + fn two_queued_toggles_resolve_from_live_index_and_round_trip() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("f.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, _sleep_calls) = fresh_queue(); + queue.enqueue(ToggleOp { path: "f.txt" }); + queue.enqueue(ToggleOp { path: "f.txt" }); + + let outcomes = queue.drain(repo, &CliApplier); + + assert!(outcomes + .iter() + .all(|o| matches!(o, OpOutcome::Completed(_)))); + fixture.assert(predicate::repo::has_untracked_file("f.txt")); + } + + #[test] + fn lock_failure_then_success_retries_exactly_once() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyLockOp { + attempts_to_fail: 1, + calls: 0, + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(outcome, OpOutcome::Completed(_))); + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected exactly one retry sleep" + ); + } + + #[test] + fn lock_failure_twice_fails_with_index_locked_after_two_attempts() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyLockOp { + attempts_to_fail: 2, + calls: 0, + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + match outcome { + OpOutcome::Failed(_, ApplyError::IndexLocked { attempts }) => { + assert_eq!(attempts, 2); + } + other => panic!("expected Failed(IndexLocked{{attempts: 2}}), got {other:?}"), + } + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected only one retry sleep, not one per attempt" + ); + } + + #[test] + fn non_lock_error_fails_immediately_without_retry() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(AlwaysNonLockErrorOp); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!( + outcome, + OpOutcome::Failed(_, ApplyError::GitSpawn(_)) + )); + assert_eq!( + *sleep_calls.lock().unwrap(), + 0, + "non-lock errors must not retry" + ); + } + + #[test] + fn panicking_op_is_contained_and_queue_remains_usable() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let log = Arc::new(Mutex::new(Vec::new())); + let (mut queue, _sleep_calls) = fresh_queue(); + queue.enqueue(PanickingOp); + // `id_slot` need not match the queue-assigned `OpId` (that's only known once + // `enqueue` returns) — the log just needs one entry to prove this op ran. + let following = queue.enqueue(RecordingOp { + id_slot: 1, + log: Arc::clone(&log), + }); + + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let first = queue.pump(repo, &CliApplier).expect("pump"); + std::panic::set_hook(prev_hook); + + assert!(matches!(first, OpOutcome::Panicked(_))); + + let second = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(second, OpOutcome::Completed(id) if id == following)); + assert_eq!( + log.lock().unwrap().len(), + 1, + "expected the following op to run once" + ); + } +} From b6d0f04a9f3754c7acb33c1a428e4db89868645f Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:13:08 -0400 Subject: [PATCH 13/18] feat(review): add refresh coordinator with generation supersede --- git-workon-review/src/lib.rs | 7 +- git-workon-review/src/refresh.rs | 232 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 git-workon-review/src/refresh.rs diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 4ecf8118..f3aa6f67 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -8,9 +8,9 @@ //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s //! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file -//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), and the FIFO staging queue -//! ([`queue`]) exist; the refresh coordinator and the round-trip verdict corpus land in later -//! M2 changesets. +//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), the FIFO staging queue +//! ([`queue`]), and the refresh generation coordinator ([`refresh`]) exist; the round-trip +//! verdict corpus lands in the next M2 changeset. pub mod acquire; pub mod apply; @@ -19,4 +19,5 @@ pub mod file_ops; pub mod model; pub mod ops; pub mod queue; +pub mod refresh; pub mod synthesis; diff --git a/git-workon-review/src/refresh.rs b/git-workon-review/src/refresh.rs new file mode 100644 index 00000000..52e645ea --- /dev/null +++ b/git-workon-review/src/refresh.rs @@ -0,0 +1,232 @@ +//! Refresh generation/livelock coordination (trap 5): a pure state machine tracking which +//! re-diff is the latest one requested, so a slow refresh that finishes after a newer one has +//! already started doesn't clobber fresher results. +//! +//! ## Interlock with `queue.rs` +//! +//! [`RefreshCoordinator::note_index_event`] refuses to schedule a refresh while +//! [`crate::queue::StagingQueue::len`] is nonzero (passed in as `staging_queue_len`) — a +//! refresh only makes sense once the queue has drained, since an in-flight staging op is about +//! to change the index again anyway (trap 4/5 interlock). +//! +//! ## M4 wiring intent (forward-looking; not built here) +//! +//! A filesystem watcher will call [`RefreshCoordinator::note_index_event`] whenever `.git/index` +//! changes, using [`IndexSignature::read`] to build the signature. When it returns `true`, the +//! caller starts an async re-diff, calling [`RefreshCoordinator::begin`] before starting the +//! work and [`RefreshCoordinator::complete`] when it finishes. + +use std::io; +use std::path::Path; + +/// A cheap fingerprint of `.git/index`'s on-disk state (mtime + size), used to distinguish a +/// genuinely new index write from an echo of one this process just made itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndexSignature { + pub mtime_sec: i64, + pub mtime_nsec: i64, + pub size: u64, +} + +impl IndexSignature { + /// Read the current signature of `/index`. M4 convenience for wiring a real + /// filesystem watcher; M2's tests use synthetic signatures (see `tests` below) since the + /// coordinator's logic never inspects the fields itself, only compares whole signatures. + pub fn read(git_dir: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = std::fs::metadata(git_dir.join("index"))?; + Ok(IndexSignature { + mtime_sec: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + size: metadata.size(), + }) + } +} + +/// A ticket for one in-flight refresh, returned by [`RefreshCoordinator::begin`] and consumed by +/// [`RefreshCoordinator::complete`]. Carries the generation it was started at; fields are +/// private, only constructed by `begin`. +#[derive(Debug, Clone, Copy)] +pub struct RefreshTicket { + generation: u64, +} + +/// What a completing refresh should do with its result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Completion { + /// This was the latest refresh started — commit its result. + Commit, + /// A newer refresh has started since this one began — discard this result, a fresher one + /// is already on the way. + Superseded, +} + +/// Generation counter + last-seen index signature, implementing the trap-5 supersede/livelock +/// invariants. See the module docs for the `queue.rs` interlock and the M4 wiring intent. +pub struct RefreshCoordinator { + next_gen: u64, + latest_started: u64, + last_signature: Option, +} + +impl Default for RefreshCoordinator { + fn default() -> Self { + Self::new() + } +} + +impl RefreshCoordinator { + pub fn new() -> Self { + Self { + next_gen: 1, + latest_started: 0, + last_signature: None, + } + } + + /// Whether an index-change event at `sig` should schedule a new refresh. + /// + /// `false` if `staging_queue_len > 0` — a staging op is still in flight, so the index is + /// about to change again; wait for the queue to drain (the `queue.rs` interlock). `false` if + /// `sig` matches `last_signature` — this is the echo of a write this process already + /// accounted for (see `complete`'s doc comment for how that signature got recorded). `true` + /// otherwise: a genuinely new, unseen index state with no staging op in flight. + /// + /// This method deliberately does NOT update `last_signature` itself — only `complete` does. + /// If it recorded the signature here, a genuinely external change event would be "seen" and + /// suppressed the moment it arrived, before any refresh even ran to observe it; worse, the + /// signature that actually needs recording is the one a refresh completes with, at the + /// specific point trap 5 cares about (see `complete`), not the raw event that triggered the + /// refresh in the first place. Comparisons live here; recording lives in `complete`. + pub fn note_index_event(&mut self, sig: IndexSignature, staging_queue_len: usize) -> bool { + if staging_queue_len > 0 { + return false; + } + if self.last_signature == Some(sig) { + return false; + } + true + } + + /// Start a new refresh, returning a ticket carrying its generation. The generation becomes + /// `self`'s `latest_started`, so any ticket from an earlier `begin` call will be superseded + /// at `complete` time. + pub fn begin(&mut self) -> RefreshTicket { + let generation = self.next_gen; + self.next_gen += 1; + self.latest_started = generation; + RefreshTicket { generation } + } + + /// Complete a refresh. `sig_at_completion` is the index signature observed AT THE MOMENT the + /// refresh finished (not when it started) — the refresh's own diffing work may itself have + /// touched the index's stat cache, so the signature must be captured fresh here. + /// + /// INVARIANT (the livelock fix): `sig_at_completion` is recorded into `last_signature` + /// UNCONDITIONALLY, before the generation check decides `Commit` vs `Superseded` — including + /// on the LOSING (`Superseded`) path. If a losing completion skipped recording its signature, + /// the stat-cache rewrite its own diffing caused would arrive at the watcher as an + /// apparently-new, never-seen index event, `note_index_event` would say "schedule a + /// refresh" for it, and under a storm of staging changes each losing refresh's echo would + /// re-trigger another refresh forever. Recording unconditionally — even on the losing path — + /// means that specific echo is always exactly the "already seen" signature, so it gets + /// suppressed instead of livelocking the refresh loop. + pub fn complete( + &mut self, + ticket: RefreshTicket, + sig_at_completion: IndexSignature, + ) -> Completion { + self.last_signature = Some(sig_at_completion); + if ticket.generation == self.latest_started { + Completion::Commit + } else { + Completion::Superseded + } + } +} + +#[cfg(test)] +mod tests { + use git_workon_fixture::prelude::*; + + use super::*; + + fn sig(n: i64) -> IndexSignature { + IndexSignature { + mtime_sec: n, + mtime_nsec: 0, + size: n as u64, + } + } + + #[test] + fn single_begin_complete_pair_commits() { + let mut coordinator = RefreshCoordinator::new(); + let ticket = coordinator.begin(); + assert_eq!(coordinator.complete(ticket, sig(1)), Completion::Commit); + } + + #[test] + fn last_writer_wins_among_two_in_flight_refreshes() { + let mut coordinator = RefreshCoordinator::new(); + let a = coordinator.begin(); + let b = coordinator.begin(); + + assert_eq!(coordinator.complete(a, sig(1)), Completion::Superseded); + assert_eq!(coordinator.complete(b, sig(2)), Completion::Commit); + } + + /// THE LIVELOCK INVARIANT: a losing (`Superseded`) completion still records its signature, + /// so the echo of its own write is suppressed by a subsequent `note_index_event` rather than + /// re-triggering another refresh — see `complete`'s doc comment for the full story. + #[test] + fn losing_completion_still_records_signature_to_suppress_echo() { + let mut coordinator = RefreshCoordinator::new(); + let a = coordinator.begin(); + let b = coordinator.begin(); + + assert_eq!(coordinator.complete(a, sig(2)), Completion::Superseded); + assert!( + !coordinator.note_index_event(sig(2), 0), + "the losing completion's own write should be suppressed as an echo" + ); + + assert_eq!(coordinator.complete(b, sig(3)), Completion::Commit); + assert!( + !coordinator.note_index_event(sig(3), 0), + "the winning completion's own write should also be suppressed as an echo" + ); + assert!( + coordinator.note_index_event(sig(4), 0), + "a genuinely new external change must still schedule a refresh" + ); + } + + #[test] + fn queue_gate_blocks_refresh_until_drained() { + let mut coordinator = RefreshCoordinator::new(); + + assert!( + !coordinator.note_index_event(sig(5), 1), + "a nonzero staging queue must block scheduling, even for a new signature" + ); + assert!( + coordinator.note_index_event(sig(5), 0), + "the same signature should schedule once the queue has drained" + ); + } + + #[test] + fn index_signature_read_reads_a_real_index_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("f.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let sig = IndexSignature::read(repo.path()).expect("read index signature"); + assert!(sig.size > 0, "expected a nonzero index file size"); + } +} From 411d2058edbe95ee69beceda28bee1a59715082f Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:51:37 -0400 Subject: [PATCH 14/18] fix(review): fix staging pathspec glob and retry error swallow --- git-workon-review/src/queue.rs | 79 +++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs index 900eb7ec..fbed8bfb 100644 --- a/git-workon-review/src/queue.rs +++ b/git-workon-review/src/queue.rs @@ -46,6 +46,10 @@ pub struct OpContext<'a> { pub fn path_has_staged_changes(repo: &Repository, path: &str) -> Result { let mut opts = StatusOptions::new(); opts.pathspec(path); + // Without this, `pathspec` treats `path` as a glob — a path containing glob metacharacters + // (e.g. `app/[slug]/page.tsx`) then never matches itself, and this always resolves "no + // staged changes" regardless of the real index state. + opts.disable_pathspec_match(true); opts.include_untracked(true); let statuses = repo.statuses(Some(&mut opts))?; let index_bits = git2::Status::INDEX_NEW @@ -160,7 +164,14 @@ impl StagingQueue { }; match catch_unwind(AssertUnwindSafe(|| op.run(&retry_ctx))) { Ok(Ok(())) => OpOutcome::Completed(id), - Ok(Err(_)) => OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }), + // Only a SECOND lock-contention error collapses into the "gave up + // retrying" outcome — any other error on retry is its own distinct + // failure and must propagate as itself, not be swallowed under a + // misleading `IndexLocked` label. + Ok(Err(e)) if crate::apply::is_lock_contention(&e) => { + OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }) + } + Ok(Err(e)) => OpOutcome::Failed(id, e), Err(_) => OpOutcome::Panicked(id), } } @@ -266,6 +277,24 @@ mod tests { } } + /// A fake op that fails with a lock error on its first call, then a DIFFERENT (non-lock) + /// error on every call after — the shape that catches the retry-arm bug: a second failure + /// that isn't itself a lock error must surface as itself, not get relabeled `IndexLocked`. + struct FlakyThenDifferentErrorOp { + calls: u32, + } + + impl StagingOp for FlakyThenDifferentErrorOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.calls += 1; + if self.calls == 1 { + Err(lock_error()) + } else { + Err(ApplyError::GitSpawn(std::io::Error::other("boom"))) + } + } + } + /// An op whose `run` panics unconditionally. struct PanickingOp; @@ -364,6 +393,25 @@ mod tests { assert_eq!(*seen.lock().unwrap(), Some(2)); } + /// Regression: `path_has_staged_changes` must match a literal path even when it contains + /// glob metacharacters (`[...]`) — without `disable_pathspec_match`, `StatusOptions::pathspec` + /// treats `path` as a glob, and a bracketed path like `app/[slug]/page.tsx` never matches + /// itself, so this always resolved "no staged changes" regardless of the real index state. + #[test] + fn path_has_staged_changes_matches_bracketed_path() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("app/[slug]/page.tsx", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + assert!( + path_has_staged_changes(repo, "app/[slug]/page.tsx").expect("path_has_staged_changes"), + "expected the bracketed path's staged entry to be found" + ); + } + /// Two toggles queued for the same path, starting unstaged: if an implementation resolved /// "stage" once at enqueue time and reused it, both ops would stage, leaving the file /// staged. Because `ToggleOp::run` calls `path_has_staged_changes` against the LIVE index @@ -446,6 +494,35 @@ mod tests { ); } + /// Regression: a lock error on the first attempt followed by a DIFFERENT (non-lock) error + /// on the retry must surface as that second error, not get collapsed into + /// `IndexLocked{attempts: 2}` — the retry arm previously matched `Ok(Err(_))` unconditionally + /// on the second attempt, swallowing whatever error actually occurred. + #[test] + fn lock_failure_then_different_error_propagates_that_error() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyThenDifferentErrorOp { calls: 0 }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!( + matches!(outcome, OpOutcome::Failed(_, ApplyError::GitSpawn(_))), + "expected the retry's own GitSpawn error to propagate, got {outcome:?}" + ); + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected exactly one retry sleep (only the first attempt was a lock error)" + ); + } + #[test] fn non_lock_error_fails_immediately_without_retry() { let fixture = FixtureBuilder::new() From c2cad21ef758dd6018344fc4b9e19a09997c0671 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:36:49 -0400 Subject: [PATCH 15/18] test(review): round-trip corpus across git2 and CLI appliers --- git-workon-review/tests/roundtrip_corpus.rs | 972 ++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 git-workon-review/tests/roundtrip_corpus.rs diff --git a/git-workon-review/tests/roundtrip_corpus.rs b/git-workon-review/tests/roundtrip_corpus.rs new file mode 100644 index 00000000..1e782802 --- /dev/null +++ b/git-workon-review/tests/roundtrip_corpus.rs @@ -0,0 +1,972 @@ +//! The round-trip verdict corpus (trap 6): every write-path scenario from M2's trap corpus, +//! driven through the `ops.rs` entry points and run against BOTH backends — +//! [`workon_review::apply::CliApplier`] (the oracle) and [`workon_review::apply::Git2Applier`] +//! (the backend under verification). +//! +//! This corpus is the PERMANENT dual-backend guard for the write path: a future libgit2 upgrade +//! flips the verdict recorded in `docs/rfc/workon-review.md` WITH EVIDENCE by making +//! `corpus_against_git2` fail here, not by someone remembering to re-audit the appliers by hand. +//! +//! The corpus asserts END-STATE equivalence only — index bytes, workdir bytes, error taxonomy — +//! never patch-byte equivalence. `Git2Applier`'s `Reverse` direction is `PatchText::invert()` +//! followed by a forward apply (libgit2's `Repository::apply` has no reverse flag), so the +//! bytes it hands to libgit2 for an unstage/discard legitimately differ from what `CliApplier` +//! sends `git apply --reverse` on stdin, even when both backends land the repository in the +//! identical state. Comparing intermediate patch bytes would produce false divergences; the two +//! tests below never do. + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; + +use git2::Repository; +use git_workon_fixture::prelude::*; +use workon_review::acquire::{diff_committed, diff_uncommitted}; +use workon_review::apply::{Applier, CliApplier, Git2Applier, StageVerb}; +use workon_review::error::{ReviewError, SynthesisError}; +use workon_review::model::{FileChange, FileStatus, LineKind}; +use workon_review::ops::{apply_file, apply_hunk, apply_lines}; +use workon_review::synthesis::LineSelection; + +/// Find the `hunk.lines` index of the first line of `kind` whose content matches `content` +/// exactly — lets scenarios key a [`LineSelection`] off readable content instead of hard-coded +/// positions (borrowed from `tests/line_synthesis.rs`'s helper of the same name). +fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) -> usize { + file.hunks[hunk_idx] + .lines + .iter() + .position(|l| l.kind == kind && l.content == content.as_bytes()) + .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) +} + +type BuildFn = fn() -> Fixture; +type OpsFn = fn(&Repository, &dyn Applier) -> Result<(), ReviewError>; +type VerifyFn = fn(&Fixture); + +/// One row of the corpus: builds a fixture, drives it through `ops.rs`, then asserts end-state. +/// `ops`/`verify` are plain function pointers (no captured state) — a scenario that needs to +/// thread specifics from build to verify bakes them into its own functions as literals rather +/// than widening this struct. +struct Scenario { + name: &'static str, + build: BuildFn, + ops: OpsFn, + verify: VerifyFn, +} + +fn scenarios() -> Vec { + vec![ + Scenario { + name: "whole_hunk_stage", + build: whole_hunk_build, + ops: whole_hunk_stage_ops, + verify: whole_hunk_stage_verify, + }, + Scenario { + name: "whole_hunk_unstage", + build: whole_hunk_build, + ops: whole_hunk_unstage_ops, + verify: whole_hunk_unstage_verify, + }, + Scenario { + name: "whole_hunk_discard", + build: whole_hunk_build, + ops: whole_hunk_discard_ops, + verify: whole_hunk_discard_verify, + }, + Scenario { + name: "partial_stage_adds_only", + build: adds_only_build, + ops: partial_stage_adds_only_ops, + verify: partial_stage_adds_only_verify, + }, + Scenario { + name: "partial_stage_dels_only", + build: dels_only_build, + ops: partial_stage_dels_only_ops, + verify: partial_stage_dels_only_verify, + }, + Scenario { + name: "partial_stage_mixed", + build: two_change_build, + ops: partial_stage_mixed_ops, + verify: partial_stage_mixed_verify, + }, + Scenario { + name: "partial_unstage", + build: two_change_build, + ops: partial_unstage_ops, + verify: partial_unstage_verify, + }, + Scenario { + name: "partial_discard", + build: two_change_build, + ops: partial_discard_ops, + verify: partial_discard_verify, + }, + Scenario { + name: "eofnl_whole_hunk_stage", + build: eofnl_whole_build, + ops: eofnl_whole_stage_ops, + verify: eofnl_whole_stage_verify, + }, + Scenario { + name: "eofnl_whole_hunk_unstage", + build: eofnl_whole_build, + ops: eofnl_whole_unstage_ops, + verify: eofnl_whole_unstage_verify, + }, + Scenario { + name: "eofnl_whole_hunk_discard", + build: eofnl_whole_build, + ops: eofnl_whole_discard_ops, + verify: eofnl_whole_discard_verify, + }, + Scenario { + name: "eofnl_partial_splice_stage", + build: eofnl_splice_build, + ops: eofnl_splice_stage_ops, + verify: eofnl_splice_stage_verify, + }, + Scenario { + name: "multi_hunk_single_hunk_staged", + build: multi_hunk_build, + ops: multi_hunk_ops, + verify: multi_hunk_verify, + }, + Scenario { + name: "space_in_filename_stage", + build: space_in_filename_build, + ops: space_in_filename_ops, + verify: space_in_filename_verify, + }, + Scenario { + name: "rename_read_only", + build: rename_build, + ops: rename_ops, + verify: rename_verify, + }, + Scenario { + name: "untracked_stage", + build: untracked_build, + ops: untracked_stage_ops, + verify: untracked_stage_verify, + }, + Scenario { + name: "deleted_stage", + build: deleted_build, + ops: deleted_stage_ops, + verify: deleted_stage_verify, + }, + Scenario { + name: "discard_untracked", + build: untracked_build, + ops: discard_untracked_ops, + verify: discard_untracked_verify, + }, + Scenario { + name: "unstage_staged_new", + build: staged_new_build, + ops: unstage_staged_new_ops, + verify: unstage_staged_new_verify, + }, + Scenario { + name: "refusal_lines_on_untracked", + build: untracked_build, + ops: refusal_lines_on_untracked_ops, + verify: refusal_lines_on_untracked_verify, + }, + Scenario { + name: "refusal_lines_on_deleted", + build: deleted_build, + ops: refusal_lines_on_deleted_ops, + verify: refusal_lines_on_deleted_verify, + }, + Scenario { + name: "staging_storm", + build: staging_storm_build, + ops: staging_storm_ops, + verify: staging_storm_verify, + }, + ] +} + +// --------------------------------------------------------------------------------------------- +// whole-hunk stage/unstage/discard +// --------------------------------------------------------------------------------------------- + +const WHOLE_HUNK_COMMITTED: &str = "line1\nline2\nline3\n"; +const WHOLE_HUNK_MODIFIED: &str = "line1\nCHANGED\nline3\n"; + +fn whole_hunk_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", WHOLE_HUNK_COMMITTED, WHOLE_HUNK_MODIFIED) + .build() + .expect("fixture build") +} + +fn whole_hunk_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn whole_hunk_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + WHOLE_HUNK_MODIFIED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + WHOLE_HUNK_MODIFIED.as_bytes().to_vec(), + )); +} + +fn whole_hunk_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: stage the full modification directly so the staged model sees it — the Unstage + // patch's preimage is the index (plan risk #3), not what's under test here. + // + // `read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle + // can carry an in-memory index cached from before the fixture builder's baseline commit, and + // `write()` after `add_path` would otherwise silently drop every OTHER path's entries back + // out of the on-disk index (harmless with one file in the fixture, corrupting with more than + // one — see `staging_storm_ops`, which needs this for real). + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Unstage) +} + +fn whole_hunk_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + WHOLE_HUNK_COMMITTED.as_bytes().to_vec(), + )); +} + +fn whole_hunk_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Discard) +} + +fn whole_hunk_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + WHOLE_HUNK_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// partial stage: adds-only / dels-only / mixed; partial unstage; partial discard +// --------------------------------------------------------------------------------------------- + +const ADDS_ONLY_COMMITTED: &str = "line1\nline2\nline3\n"; +const ADDS_ONLY_MODIFIED: &str = "line1\nNEW\nline2\nline3\n"; + +fn adds_only_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", ADDS_ONLY_COMMITTED, ADDS_ONLY_MODIFIED) + .build() + .expect("fixture build") +} + +fn partial_stage_adds_only_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "NEW\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_adds_only_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + ADDS_ONLY_MODIFIED.as_bytes().to_vec(), + )); +} + +const DELS_ONLY_COMMITTED: &str = "line1\nline2\nline3\n"; +const DELS_ONLY_MODIFIED: &str = "line1\nline3\n"; + +fn dels_only_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", DELS_ONLY_COMMITTED, DELS_ONLY_MODIFIED) + .build() + .expect("fixture build") +} + +fn partial_stage_dels_only_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_del = line_index(file, 0, LineKind::Deletion, "line2\n"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_dels_only_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + DELS_ONLY_MODIFIED.as_bytes().to_vec(), + )); +} + +/// Two separate changes ("old2"->"new2", "old4"->"new4") in one hunk, separated by a context +/// line — the shape the direction rules (trap 1) need: keeping one change and dropping the +/// other must not treat the dropped one uniformly across stage/unstage/discard. +const TWO_CHANGE_COMMITTED: &str = "line1\nold2\nline3\nold4\nline5\n"; +const TWO_CHANGE_MODIFIED: &str = "line1\nnew2\nline3\nnew4\nline5\n"; + +fn two_change_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", TWO_CHANGE_COMMITTED, TWO_CHANGE_MODIFIED) + .build() + .expect("fixture build") +} + +fn first_change_selection(file: &FileChange) -> LineSelection { + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + } +} + +fn partial_stage_mixed_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_mixed_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nnew2\nline3\nold4\nline5\n".to_vec(), + )); +} + +fn partial_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: stage the full modification first so the staged model (the correct preimage for + // an Unstage patch) sees both changes. `read(true)`: see `whole_hunk_unstage_ops`. + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Unstage) +} + +fn partial_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); +} + +fn partial_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Discard) +} + +fn partial_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + TWO_CHANGE_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// EOFNL per verb (whole-hunk) + the trap-2 splice case (partial) +// --------------------------------------------------------------------------------------------- + +const EOFNL_WHOLE_COMMITTED: &str = "line1\nline2\nline3\n"; +const EOFNL_WHOLE_MODIFIED: &str = "line1\nline2\nline3"; // no trailing newline + +fn eofnl_whole_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", EOFNL_WHOLE_COMMITTED, EOFNL_WHOLE_MODIFIED) + .build() + .expect("fixture build") +} + +fn eofnl_whole_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn eofnl_whole_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + EOFNL_WHOLE_MODIFIED.as_bytes().to_vec(), + )); +} + +fn eofnl_whole_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // `read(true)`: see `whole_hunk_unstage_ops`. + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Unstage) +} + +fn eofnl_whole_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + EOFNL_WHOLE_COMMITTED.as_bytes().to_vec(), + )); +} + +fn eofnl_whole_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Discard) +} + +fn eofnl_whole_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + EOFNL_WHOLE_COMMITTED.as_bytes().to_vec(), + )); +} + +/// Trap-2's precondition: the committed file's last line ("last") has no trailing newline; the +/// modification deletes that line and adds two new ones, the last of which ("more\n") DOES end +/// in a newline. Keeping only "more\n" drops the "last" deletion to context while it still +/// carries `missing_newline` — the shape `splice_eofnl_context_lines` exists to rewrite. +const EOFNL_SPLICE_COMMITTED: &str = "a\nb\nlast"; +const EOFNL_SPLICE_MODIFIED: &str = "a\nb\nreplaced\nmore\n"; + +fn eofnl_splice_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", EOFNL_SPLICE_COMMITTED, EOFNL_SPLICE_MODIFIED) + .build() + .expect("fixture build") +} + +fn eofnl_splice_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn eofnl_splice_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlast\nmore\n".to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// multi-hunk file: two changes far enough apart to form separate hunks; stage only one +// --------------------------------------------------------------------------------------------- + +fn multi_hunk_committed() -> String { + (1..=20).map(|n| format!("line{n}\n")).collect() +} + +fn multi_hunk_modified() -> String { + (1..=20) + .map(|n| match n { + 2 => "CHANGED2\n".to_string(), + 18 => "CHANGED18\n".to_string(), + n => format!("line{n}\n"), + }) + .collect() +} + +fn multi_hunk_hunk0_only() -> String { + (1..=20) + .map(|n| match n { + 2 => "CHANGED2\n".to_string(), + n => format!("line{n}\n"), + }) + .collect() +} + +fn multi_hunk_build() -> Fixture { + let committed = multi_hunk_committed(); + let modified = multi_hunk_modified(); + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .expect("fixture build") +} + +fn multi_hunk_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!( + file.hunks.len(), + 2, + "expected two separate hunks for the multi-hunk fixture, got {}", + file.hunks.len() + ); + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn multi_hunk_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + multi_hunk_hunk0_only().into_bytes(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + multi_hunk_modified().into_bytes(), + )); +} + +// --------------------------------------------------------------------------------------------- +// space-in-filename: proves header path handling through both parsers +// --------------------------------------------------------------------------------------------- + +const SPACE_FILENAME_COMMITTED: &str = "line1\nline2\nline3\n"; +const SPACE_FILENAME_MODIFIED: &str = "line1\nCHANGED\nline3\n"; + +fn space_in_filename_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "my file.txt", + SPACE_FILENAME_COMMITTED, + SPACE_FILENAME_MODIFIED, + ) + .build() + .expect("fixture build") +} + +fn space_in_filename_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn space_in_filename_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "my file.txt", + SPACE_FILENAME_MODIFIED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// rename: READ-side only. Rename patches only arise from tree_to_tree diffs (diff_committed), +// never from uncommitted diffs — staging a rename hunk against the index is not a v1 write op, +// so this scenario has no write side to drive through ops.rs; `ops` is a deliberate no-op and +// the assertions live entirely in `verify`. +// --------------------------------------------------------------------------------------------- + +const RENAME_CONTENT: &str = "shared content across the rename\nline two\nline three\n"; + +fn rename_build() -> Fixture { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build() + .expect("fixture build"); + + fixture + .commit("main") + .file("old.txt", RENAME_CONTENT) + .create("add old.txt") + .expect("commit old.txt"); + + // CommitBuilder only adds files; build the rename commit by hand (remove old.txt, add + // new.txt with identical content so `find_similar` detects it as a rename, not a + // delete+add pair). Scoped so every borrow of `fixture`/`repo` ends before it's returned. + { + let repo = fixture.repo().expect("repo"); + let workdir = repo.workdir().expect("workdir"); + std::fs::remove_file(workdir.join("old.txt")).expect("remove old.txt"); + std::fs::write(workdir.join("new.txt"), RENAME_CONTENT).expect("write new.txt"); + + let mut index = repo.index().expect("index"); + index + .remove_path(Path::new("old.txt")) + .expect("remove_path"); + index.add_path(Path::new("new.txt")).expect("add_path"); + index.write().expect("index write"); + let tree_id = index.write_tree().expect("write_tree"); + let tree = repo.find_tree(tree_id).expect("find_tree"); + let sig = git2::Signature::now("Test User", "test@example.com").expect("signature"); + let parent = repo.head().expect("head").peel_to_commit().expect("peel"); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "rename old.txt to new.txt", + &tree, + &[&parent], + ) + .expect("commit rename"); + } + + fixture +} + +fn rename_ops(_repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + Ok(()) +} + +fn rename_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + let head_commit = repo.head().expect("head").peel_to_commit().expect("peel"); + let head_oid = head_commit.id(); + let base_oid = head_commit.parent(0).expect("parent commit").id(); + + let diff = diff_committed(repo, base_oid, head_oid).expect("diff_committed"); + let file = diff + .files + .iter() + .find(|f| f.path == "new.txt") + .expect("renamed file present as new.txt"); + + assert_eq!( + file.status, + FileStatus::Renamed, + "expected Renamed status, got {:?}", + file.status + ); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); +} + +// --------------------------------------------------------------------------------------------- +// untracked / added / deleted file ops +// --------------------------------------------------------------------------------------------- + +fn untracked_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build") +} + +fn untracked_stage_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage) +} + +fn untracked_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_staged_file("new.txt")); + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\n".to_vec(), + )); +} + +fn deleted_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build") +} + +fn deleted_stage_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage) +} + +fn deleted_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_staged_deletion("gone.txt")); +} + +fn discard_untracked_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard) +} + +fn discard_untracked_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + assert!(!repo.workdir().unwrap().join("new.txt").exists()); +} + +fn staged_new_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\n") + .build() + .expect("fixture build") +} + +fn unstage_staged_new_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_file(repo, file, StageVerb::Unstage) +} + +fn unstage_staged_new_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_untracked_file("added.txt")); + let repo = fixture.repo().expect("repo"); + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index.get_path(Path::new("added.txt"), 0).is_none(), + "expected no index entry for added.txt after unstage" + ); +} + +// --------------------------------------------------------------------------------------------- +// refusals: apply_lines on untracked/deleted files never reaches an applier, so these can never +// diverge between backends — kept for grid completeness per the plan. +// --------------------------------------------------------------------------------------------- + +fn refusal_lines_on_untracked_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage); + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); + Ok(()) +} + +fn refusal_lines_on_untracked_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_untracked_file("new.txt")); +} + +fn refusal_lines_on_deleted_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage); + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); + Ok(()) +} + +fn refusal_lines_on_deleted_verify(_fixture: &Fixture) { + // Nothing was ever applied — the refusal itself (asserted in `ops`) is the whole scenario. +} + +// --------------------------------------------------------------------------------------------- +// staging storm: stage a subset, unstage a different subset, discard the rest in one sequence, +// then assert the exact three-way (index/workdir) end state. Driven through `apply_hunk` (not +// `apply_file`) so the sequence exercises the `Applier` on all three files, not just index +// plumbing — `StagingQueue` integration was considered (plan explicitly allows it) but skipped: +// queue.rs's own test suite already covers FIFO/live-index semantics, and wiring `StagingOp` +// here would test queue plumbing instead of the storm's actual end-state, which is the point of +// this scenario. +// --------------------------------------------------------------------------------------------- + +const STORM_A_COMMITTED: &str = "a1\na2\na3\n"; +const STORM_A_MODIFIED: &str = "a1\nCHANGED_A\na3\n"; +const STORM_B_COMMITTED: &str = "b1\nb2\nb3\n"; +const STORM_B_MODIFIED: &str = "b1\nCHANGED_B\nb3\n"; +const STORM_C_COMMITTED: &str = "c1\nc2\nc3\n"; +const STORM_C_MODIFIED: &str = "c1\nCHANGED_C\nc3\n"; + +fn staging_storm_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("fileA.txt", STORM_A_COMMITTED, STORM_A_MODIFIED) + .unstaged_file("fileB.txt", STORM_B_COMMITTED, STORM_B_MODIFIED) + .unstaged_file("fileC.txt", STORM_C_COMMITTED, STORM_C_MODIFIED) + .build() + .expect("fixture build") +} + +fn staging_storm_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: pre-stage fileB's modification (not part of the storm itself) so the storm can + // unstage it. `read(true)` is load-bearing here, not defensive: `Fixture`'s `Repository` + // handle can carry an in-memory index cached from before the fixture builder's baseline + // commit (which added fileA/fileB/fileC together); without a reload first, `add_path` + + // `write` writes back only what THIS index object knows about, silently dropping fileA's + // and fileC's index entries (verified empirically — the naive form staged all three files + // as untracked-since-deleted). + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("fileB.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file_a = diffs + .unstaged + .files + .iter() + .find(|f| f.path == "fileA.txt") + .expect("fileA.txt in unstaged diff"); + let file_c = diffs + .unstaged + .files + .iter() + .find(|f| f.path == "fileC.txt") + .expect("fileC.txt in unstaged diff"); + let file_b = diffs + .staged + .files + .iter() + .find(|f| f.path == "fileB.txt") + .expect("fileB.txt in staged diff"); + + apply_hunk(repo, applier, file_a, 0, StageVerb::Stage)?; + apply_hunk(repo, applier, file_b, 0, StageVerb::Unstage)?; + apply_hunk(repo, applier, file_c, 0, StageVerb::Discard)?; + Ok(()) +} + +fn staging_storm_verify(fixture: &Fixture) { + // Staged: index has the change, workdir untouched (Stage targets the index only). + fixture.assert(predicate::repo::index_blob_equals( + "fileA.txt", + STORM_A_MODIFIED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "fileA.txt", + STORM_A_MODIFIED.as_bytes().to_vec(), + )); + // Unstaged: index reverted to HEAD, workdir untouched (Unstage targets the index only). + fixture.assert(predicate::repo::index_blob_equals( + "fileB.txt", + STORM_B_COMMITTED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "fileB.txt", + STORM_B_MODIFIED.as_bytes().to_vec(), + )); + // Discarded: workdir reverted to HEAD; index was never touched (still matches HEAD). + fixture.assert(predicate::repo::workdir_file_equals( + "fileC.txt", + STORM_C_COMMITTED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::index_blob_equals( + "fileC.txt", + STORM_C_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// The verdict tests +// --------------------------------------------------------------------------------------------- + +/// The ORACLE: every scenario must pass cleanly against `CliApplier`. No skip-if-missing — the +/// `git` binary is required. A panic here is a real bug in the corpus or the write path, not a +/// divergence to collect. +#[test] +fn corpus_against_cli() { + for scenario in scenarios() { + let fixture = (scenario.build)(); + let repo = fixture.repo().expect("repo"); + (scenario.ops)(repo, &CliApplier) + .unwrap_or_else(|err| panic!("{}: CLI ops failed: {err}", scenario.name)); + (scenario.verify)(&fixture); + } +} + +/// Run one scenario's ops+verify against `Git2Applier`, catching panics so one scenario's +/// failure doesn't abort the rest of the corpus. +/// +/// SOUNDNESS of `AssertUnwindSafe`: `&Repository` isn't `UnwindSafe`, so the closure capturing +/// `repo`/`fixture` can't be passed to `catch_unwind` without asserting it. This is sound +/// because each scenario's fixture is built fresh, used exactly once, and dropped immediately +/// after — whether or not a panic occurs — so there is no unwind-poisoned shared state for a +/// later scenario (or a later assertion on the SAME fixture) to observe. +fn run_scenario_against_git2(scenario: &Scenario) -> Result<(), String> { + let fixture = (scenario.build)(); + let outcome = catch_unwind(AssertUnwindSafe(|| { + let repo = fixture.repo().expect("repo"); + (scenario.ops)(repo, &Git2Applier).map_err(|err| format!("git2 ops errored: {err}"))?; + (scenario.verify)(&fixture); + Ok::<(), String>(()) + })); + match outcome { + Ok(Ok(())) => Ok(()), + Ok(Err(detail)) => Err(detail), + Err(_) => Err("git2 pass panicked (ops error or a verify assertion failed)".to_string()), + } +} + +/// Known divergences between `Git2Applier` and the `CliApplier` oracle, one entry per +/// `": "`. Kept explicit (rather than a bare `assert!(is_empty())`) +/// so a future divergence must be added here WITH the evidence of what it is, not silently +/// swallowed by loosening this test. +const KNOWN_DIVERGENCES: &[&str] = &[]; + +/// The VERDICT: renders the git2-vs-CLI comparison for `docs/rfc/workon-review.md`'s "M2 +/// verdict" section. Collects divergences instead of panicking per-scenario so the full set is +/// visible in one run. +#[test] +fn corpus_against_git2() { + let mut divergences: Vec = Vec::new(); + for scenario in scenarios() { + if let Err(detail) = run_scenario_against_git2(&scenario) { + divergences.push(format!("{}: {detail}", scenario.name)); + } + } + divergences.sort(); + + let mut expected: Vec = KNOWN_DIVERGENCES.iter().map(|s| s.to_string()).collect(); + expected.sort(); + + assert_eq!( + divergences, expected, + "git2 divergence set changed vs. KNOWN_DIVERGENCES — update the allowlist WITH evidence, \ + don't just widen it to pass" + ); +} From ffe7519de4d4d6f0b521c8169b9631916d3e763a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:40:15 -0400 Subject: [PATCH 16/18] docs(rfc): record git2-vs-CLI verdict and write-path decision --- docs/rfc/workon-review.md | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 784ecf56..2fb9e32e 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -48,11 +48,54 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom 6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). 7. **Metadata revisions are snapshots, not refs** (found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. +## M2 verdict (git2 vs CLI apply) + +The round-trip corpus (`git-workon-review/tests/roundtrip_corpus.rs`) drives every write-path +scenario class from the trap corpus above through `ops.rs`'s entry points against both backends. +Measured result: **0 divergences** across 22 scenarios. + +| Scenario class | git2 verdict | +|---|---| +| Whole-hunk stage/unstage/discard | pass | +| Partial stage (adds-only/dels-only/mixed) | pass | +| Partial unstage / partial discard | pass | +| EOFNL per verb (whole-hunk) | pass | +| EOFNL trap-2 splice (partial stage) | pass | +| Multi-hunk file, one hunk staged | pass | +| Space-in-filename header handling | pass | +| Rename (read-side, `diff_committed`) | pass | +| Untracked/added/deleted file ops | pass | +| Line-selection refusals (never reach an applier) | pass | +| Staging storm (mixed stage/unstage/discard, three-way end state) | pass | + +Per the plan's decision procedure: 0 divergences means **`Git2Applier` is the default write +path**; `CliApplier` is retained as the corpus's oracle and as the documented escape hatch +(`is_lock_contention` already classifies errors from both backends identically, so the seam has +no additional cost to keep). `Applier` stays a trait specifically so this can flip without +touching call sites if a future libgit2 upgrade regresses. + +`tests/roundtrip_corpus.rs` runs both backends on every `cargo test` — it is the permanent guard +this decision rests on. If a future libgit2 upgrade changes apply behavior, `corpus_against_git2` +fails with the specific scenario and divergence class, and the fix is to update +`KNOWN_DIVERGENCES` (or flip the default writer) with that evidence in hand, not to relitigate +this section from memory. + +Two tripwire findings from earlier M2 changesets are now pinned as permanent regression tests, +not just corpus coverage: + +- **Trap 3 (empty-blob deletion staging)**: `naive_hunk_stage_of_deletion_stages_empty_blob` in + `git-workon-review/tests/file_ops.rs` — a naive whole-hunk stage of a deletion is accepted by + `git apply --cached` but stages an empty blob instead of removing the index entry. +- **Trap 2 (EOFNL silent concatenation)**: `naive_unspliced_eofnl_patch_silently_corrupts_the_index` + in `git-workon-review/tests/line_synthesis.rs` — a dropped deletion converted to context while + still carrying its `\ No newline at end of file` marker, followed by a kept line, is accepted + by `git apply` (exit 0) but silently concatenates the two lines into one corrupt line. + ## Milestones - **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-033](../adr/033-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. - **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. -- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. +- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). From 9bcfca5ec40b1a7f8a87f4d6e344afa953c24f5e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:59:39 -0400 Subject: [PATCH 17/18] fix(review): downcast corpus panics and pin exec-bit scenario --- git-workon-fixture/src/fixture_builder.rs | 61 ++++++++- git-workon-fixture/src/predicates.rs | 2 + .../src/predicates/has_index_mode.rs | 49 +++++++ git-workon-fixture/src/prelude.rs | 8 +- git-workon-fixture/tests/suite/index_state.rs | 25 ++++ git-workon-review/tests/roundtrip_corpus.rs | 125 +++++++++++++----- 6 files changed, 228 insertions(+), 42 deletions(-) create mode 100644 git-workon-fixture/src/predicates/has_index_mode.rs diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index b67d6318..b2ba34b1 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -232,6 +232,7 @@ pub struct FixtureBuilder<'fixture> { gh_stack_ops: Vec, partially_staged_files: Vec<(String, String, String, String)>, // (path, committed, staged, workdir) untracked_symlinks: Vec<(String, String)>, // (path, target) — target need not exist + executable_unstaged_files: Vec<(String, String, String)>, // (path, committed, modified), mode 0o100755 } impl<'fixture> FixtureBuilder<'fixture> { @@ -256,6 +257,7 @@ impl<'fixture> FixtureBuilder<'fixture> { gh_stack_ops: Vec::new(), partially_staged_files: Vec::new(), untracked_symlinks: Vec::new(), + executable_unstaged_files: Vec::new(), } } @@ -629,6 +631,24 @@ impl<'fixture> FixtureBuilder<'fixture> { self } + /// Like [`unstaged_file`](Self::unstaged_file), but `path` is committed and rewritten with + /// the executable bit set (`chmod 0o755`) at BOTH baseline commit time and after the + /// working-tree rewrite — so `HEAD`'s (and the starting index's) mode is really + /// `0o100755`, not just the working tree's. Needed to pin the exec-bit-preserving fix: a + /// hunk stage of an executable file must not clobber its index mode back to `0o100644`. + /// + /// Unix-only ([`std::os::unix::fs::PermissionsExt`]); applies to the LAST worktree added, or + /// the main repo if none. Errors at [`build`](Self::build) if the fixture is `bare(true)` + /// with no worktree. + pub fn executable_unstaged_file(mut self, path: &str, committed: &str, modified: &str) -> Self { + self.executable_unstaged_files.push(( + path.to_string(), + committed.to_string(), + modified.to_string(), + )); + self + } + /// Create a symlink at `path` pointing at `target` in the fixture's cwd repo working tree; /// never staged (untracked). `target` need not exist — a dangling/broken symlink is still a /// real working-tree entry (`symlink_metadata`/lstat sees it; `Path::exists`, which follows @@ -752,22 +772,25 @@ impl<'fixture> FixtureBuilder<'fixture> { || !self.untracked_files.is_empty() || !self.deleted_files.is_empty() || !self.partially_staged_files.is_empty() - || !self.untracked_symlinks.is_empty(); + || !self.untracked_symlinks.is_empty() + || !self.executable_unstaged_files.is_empty(); if has_index_state && self.bare && self.worktrees.is_empty() { return Err( "staged_file/unstaged_file/untracked_file/deleted_file/partially_staged_file/\ - untracked_symlink require a working tree: fixture is bare(true) with no worktree" + untracked_symlink/executable_unstaged_file require a working tree: fixture is \ + bare(true) with no worktree" .into(), ); } - // `unstaged_file`/`deleted_file`/`partially_staged_file` baseline commits land BEFORE - // Graphite-metadata live-tip resolution below: they move the cwd branch's tip, and any - // metadata entry recording that tip must reflect the moved one, not the pre-baseline - // commit. All three builders share one baseline commit. + // `unstaged_file`/`deleted_file`/`partially_staged_file`/`executable_unstaged_file` + // baseline commits land BEFORE Graphite-metadata live-tip resolution below: they move + // the cwd branch's tip, and any metadata entry recording that tip must reflect the + // moved one, not the pre-baseline commit. All four builders share one baseline commit. if !self.unstaged_files.is_empty() || !self.deleted_files.is_empty() || !self.partially_staged_files.is_empty() + || !self.executable_unstaged_files.is_empty() { let cwd_repo = Repository::open(&cwd_path)?; let mut index = cwd_repo.index()?; @@ -795,6 +818,21 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, committed)?; index.add_path(Path::new(file_path))?; } + #[cfg(unix)] + for (file_path, committed, _modified) in &self.executable_unstaged_files { + use std::os::unix::fs::PermissionsExt; + let abs_path = cwd_path.join(file_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&abs_path, committed)?; + std::fs::set_permissions(&abs_path, std::fs::Permissions::from_mode(0o755))?; + index.add_path(Path::new(file_path))?; + } + #[cfg(not(unix))] + if !self.executable_unstaged_files.is_empty() { + return Err("executable_unstaged_file is unix-only".into()); + } index.write()?; let tree_id = index.write_tree()?; @@ -1203,6 +1241,17 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(cwd_path.join(file_path), modified)?; } + // Rewrite the working tree copy to `modified` content, then restore the exec bit — + // `std::fs::write` truncates+rewrites the file rather than editing it in place, so + // the mode set during the baseline commit above does not necessarily survive. + #[cfg(unix)] + for (file_path, _committed, modified) in &self.executable_unstaged_files { + use std::os::unix::fs::PermissionsExt; + let abs_path = cwd_path.join(file_path); + std::fs::write(&abs_path, modified)?; + std::fs::set_permissions(&abs_path, std::fs::Permissions::from_mode(0o755))?; + } + // Rewrite the working tree copy to `workdir` content AFTER the index has `staged` // — the index entry must stay at `staged`, only the on-disk file moves further. for (file_path, _committed, _staged, workdir) in &self.partially_staged_files { diff --git a/git-workon-fixture/src/predicates.rs b/git-workon-fixture/src/predicates.rs index 4cee6ea6..bdd99d16 100644 --- a/git-workon-fixture/src/predicates.rs +++ b/git-workon-fixture/src/predicates.rs @@ -10,6 +10,7 @@ mod has_config; mod has_config_multivar; mod has_gh_stack; mod has_graphite_config; +mod has_index_mode; mod has_metadata_parent_revision; mod has_remote; mod has_remote_branch; @@ -45,6 +46,7 @@ pub use self::has_config::*; pub use self::has_config_multivar::*; pub use self::has_gh_stack::*; pub use self::has_graphite_config::*; +pub use self::has_index_mode::*; pub use self::has_metadata_parent_revision::*; pub use self::has_remote::*; pub use self::has_remote_branch::*; diff --git a/git-workon-fixture/src/predicates/has_index_mode.rs b/git-workon-fixture/src/predicates/has_index_mode.rs new file mode 100644 index 00000000..8e4ddc10 --- /dev/null +++ b/git-workon-fixture/src/predicates/has_index_mode.rs @@ -0,0 +1,49 @@ +use git2::Repository; +use predicates::prelude::Predicate; +use predicates::reflection::PredicateReflection; +use std::fmt; +use std::path::Path; + +pub struct HasIndexModePredicate { + path: String, + expected: i32, +} + +impl PredicateReflection for HasIndexModePredicate {} + +impl fmt::Display for HasIndexModePredicate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "index entry for '{}' has mode {:06o}", + self.path, self.expected + ) + } +} + +impl Predicate for HasIndexModePredicate { + fn eval(&self, repo: &Repository) -> bool { + let Ok(mut index) = repo.index() else { + return false; + }; + // See `index_blob_equals`'s doc comment: force a reload so a stale cached index handle + // doesn't report a mode from before another handle wrote the on-disk index. + if index.read(true).is_err() { + return false; + } + let Some(entry) = index.get_path(Path::new(&self.path), 0) else { + return false; + }; + entry.mode as i32 == self.expected + } +} + +/// Assert that the index entry for `path` has raw octal mode `expected` (e.g. `0o100755` for an +/// executable file) — the regression check for staging an executable file not clobbering its +/// mode back to `0o100644`. +pub fn has_index_mode(path: impl Into, expected: i32) -> HasIndexModePredicate { + HasIndexModePredicate { + path: path.into(), + expected, + } +} diff --git a/git-workon-fixture/src/prelude.rs b/git-workon-fixture/src/prelude.rs index eb814f4a..43109a8a 100644 --- a/git-workon-fixture/src/prelude.rs +++ b/git-workon-fixture/src/prelude.rs @@ -30,10 +30,10 @@ pub mod predicate { pub use crate::predicates::{ branch_points_to, gh_stack_branch_base, gh_stack_contains_branch, gh_stack_is_linked, gh_stack_preserves, has_branch, has_branch_metadata, has_config, has_gh_stack, - has_graphite_config, has_metadata_parent_revision, has_no_stash, has_remote, - has_remote_branch, has_remote_url, has_sqlite_branch_metadata, has_staged_deletion, - has_staged_file, has_stash, has_unstaged_file, has_untracked_file, has_upstream, - has_workdir_deletion, has_worktree, head_commit_message_contains, + has_graphite_config, has_index_mode, has_metadata_parent_revision, has_no_stash, + has_remote, has_remote_branch, has_remote_url, has_sqlite_branch_metadata, + has_staged_deletion, has_staged_file, has_stash, has_unstaged_file, has_untracked_file, + has_upstream, has_workdir_deletion, has_worktree, head_commit_message_contains, head_commit_parent_count, head_matches, index_blob_equals, is_bare, is_empty, is_head_detached, is_worktree, workdir_file_equals, }; diff --git a/git-workon-fixture/tests/suite/index_state.rs b/git-workon-fixture/tests/suite/index_state.rs index 95330f25..994909fc 100644 --- a/git-workon-fixture/tests/suite/index_state.rs +++ b/git-workon-fixture/tests/suite/index_state.rs @@ -401,3 +401,28 @@ fn untracked_symlink_is_visible_via_lstat_even_when_dangling( Ok(()) } + +#[cfg(unix)] +#[test] +fn executable_unstaged_file_has_100755_mode_at_head_and_on_disk( +) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let fixture = FixtureBuilder::new() + .executable_unstaged_file("run.sh", "echo committed\n", "echo modified\n") + .build()?; + + let repo = fixture.repo()?; + repo.assert(predicate::repo::has_index_mode("run.sh", 0o100755)); + + let dir = fixture.cwd()?; + let abs_path = dir.path().join("run.sh"); + let perms = std::fs::metadata(&abs_path)?.permissions(); + assert_eq!( + perms.mode() & 0o111, + 0o111, + "expected the working tree copy to keep its executable bits" + ); + + Ok(()) +} diff --git a/git-workon-review/tests/roundtrip_corpus.rs b/git-workon-review/tests/roundtrip_corpus.rs index 1e782802..cf6df13c 100644 --- a/git-workon-review/tests/roundtrip_corpus.rs +++ b/git-workon-review/tests/roundtrip_corpus.rs @@ -38,6 +38,24 @@ fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) } +/// Stage `path`'s current working-tree content directly (bypassing `ops.rs`) as scenario setup +/// — e.g. so an Unstage/Discard scenario's preimage is already staged before the op under test +/// runs. +/// +/// `index.read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle +/// can carry an in-memory index cached from before the fixture builder's baseline commit, and +/// `write()` after `add_path` would otherwise silently drop every OTHER path's entries back out +/// of the on-disk index — harmless with one file in a fixture, corrupting with more than one +/// (verified empirically in `staging_storm_ops`'s three-file fixture: the naive form staged all +/// three files as untracked-since-deleted). +fn pre_stage(repo: &Repository, path: &str) -> Result<(), ReviewError> { + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new(path))?; + index.write()?; + Ok(()) +} + type BuildFn = fn() -> Fixture; type OpsFn = fn(&Repository, &dyn Applier) -> Result<(), ReviewError>; type VerifyFn = fn(&Fixture); @@ -187,6 +205,12 @@ fn scenarios() -> Vec { ops: staging_storm_ops, verify: staging_storm_verify, }, + Scenario { + name: "executable_whole_hunk_stage", + build: executable_build, + ops: executable_whole_hunk_stage_ops, + verify: executable_whole_hunk_stage_verify, + }, ] } @@ -224,17 +248,9 @@ fn whole_hunk_stage_verify(fixture: &Fixture) { fn whole_hunk_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: stage the full modification directly so the staged model sees it — the Unstage - // patch's preimage is the index (plan risk #3), not what's under test here. - // - // `read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle - // can carry an in-memory index cached from before the fixture builder's baseline commit, and - // `write()` after `add_path` would otherwise silently drop every OTHER path's entries back - // out of the on-disk index (harmless with one file in the fixture, corrupting with more than - // one — see `staging_storm_ops`, which needs this for real). - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // patch's preimage is the index (plan risk #3), not what's under test here. See + // `pre_stage`'s docs for why `index.read(true)` is load-bearing, not defensive. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -368,11 +384,9 @@ fn partial_stage_mixed_verify(fixture: &Fixture) { fn partial_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: stage the full modification first so the staged model (the correct preimage for - // an Unstage patch) sees both changes. `read(true)`: see `whole_hunk_unstage_ops`. - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // an Unstage patch) sees both changes. See `pre_stage`'s docs for why `index.read(true)` is + // load-bearing. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -434,11 +448,8 @@ fn eofnl_whole_stage_verify(fixture: &Fixture) { } fn eofnl_whole_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { - // `read(true)`: see `whole_hunk_unstage_ops`. - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // See `pre_stage`'s docs for why `index.read(true)` is load-bearing. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -834,16 +845,10 @@ fn staging_storm_build() -> Fixture { fn staging_storm_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: pre-stage fileB's modification (not part of the storm itself) so the storm can - // unstage it. `read(true)` is load-bearing here, not defensive: `Fixture`'s `Repository` - // handle can carry an in-memory index cached from before the fixture builder's baseline - // commit (which added fileA/fileB/fileC together); without a reload first, `add_path` + - // `write` writes back only what THIS index object knows about, silently dropping fileA's - // and fileC's index entries (verified empirically — the naive form staged all three files - // as untracked-since-deleted). - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("fileB.txt"))?; - index.write()?; + // unstage it. See `pre_stage`'s docs for why `index.read(true)` is load-bearing here, not + // defensive — this fixture's three-file baseline commit is exactly the multi-path case that + // bites without the reload. + pre_stage(repo, "fileB.txt")?; let diffs = diff_uncommitted(repo)?; let file_a = diffs @@ -901,6 +906,44 @@ fn staging_storm_verify(fixture: &Fixture) { )); } +// --------------------------------------------------------------------------------------------- +// executable file (100755) whole-hunk stage — pins the exec-bit-mode divergence class the +// 2026-07-06 stack review found: `PatchText::to_bytes` used to hardcode `index 0000000..0000000 +// 100644` on the synthesized patch's index line, so staging any hunk of an executable file via +// `Git2Applier` silently reset its index mode to `100644` — a real divergence `CliApplier` never +// had (it reads the mode from the working tree). Fixed by threading the real mode through +// `FileChange`/`PatchText` (see `synthesis.rs`). +// --------------------------------------------------------------------------------------------- + +const EXECUTABLE_COMMITTED: &str = "#!/bin/sh\necho committed\n"; +const EXECUTABLE_MODIFIED: &str = "#!/bin/sh\necho modified\n"; + +fn executable_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .executable_unstaged_file("run.sh", EXECUTABLE_COMMITTED, EXECUTABLE_MODIFIED) + .build() + .expect("fixture build") +} + +fn executable_whole_hunk_stage_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn executable_whole_hunk_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "run.sh", + EXECUTABLE_MODIFIED.as_bytes().to_vec(), + )); + // The regression: staging must NOT clobber the index entry's mode back to 0o100644. + fixture.assert(predicate::repo::has_index_mode("run.sh", 0o100755)); +} + // --------------------------------------------------------------------------------------------- // The verdict tests // --------------------------------------------------------------------------------------------- @@ -938,7 +981,25 @@ fn run_scenario_against_git2(scenario: &Scenario) -> Result<(), String> { match outcome { Ok(Ok(())) => Ok(()), Ok(Err(detail)) => Err(detail), - Err(_) => Err("git2 pass panicked (ops error or a verify assertion failed)".to_string()), + Err(payload) => Err(format!( + "git2 pass panicked: {}", + panic_payload_message(&payload) + )), + } +} + +/// Extract a human-readable message from a `catch_unwind` panic payload — `panic!("{msg}")` and +/// `assert!`/`unwrap`/`expect` failures carry it as `&'static str` or `String` depending on +/// whether the message was formatted; anything else (a non-string payload) falls back to a +/// fixed placeholder so a [`KNOWN_DIVERGENCES`] entry can still cite whatever detail IS +/// available instead of a uniform, evidence-free string for every panic. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::() { + s.clone() + } else if let Some(s) = payload.downcast_ref::<&str>() { + s.to_string() + } else { + "(non-string panic payload)".to_string() } } From 7a4b10a9a9ac516f7844bd56a0f7fead2482d281 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:59:47 -0400 Subject: [PATCH 18/18] docs(rfc): record review-found exec-bit and EOFNL divergences --- docs/rfc/workon-review.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 2fb9e32e..e4799a0b 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -52,7 +52,8 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom The round-trip corpus (`git-workon-review/tests/roundtrip_corpus.rs`) drives every write-path scenario class from the trap corpus above through `ops.rs`'s entry points against both backends. -Measured result: **0 divergences** across 22 scenarios. +Measured result (updated after the 2026-07-06 stack review, see below): **0 divergences** across +23 scenarios. | Scenario class | git2 verdict | |---|---| @@ -67,6 +68,7 @@ Measured result: **0 divergences** across 22 scenarios. | Untracked/added/deleted file ops | pass | | Line-selection refusals (never reach an applier) | pass | | Staging storm (mixed stage/unstage/discard, three-way end state) | pass | +| Executable file (100755) whole-hunk stage | pass (fixed by review — was a divergence, see below) | Per the plan's decision procedure: 0 divergences means **`Git2Applier` is the default write path**; `CliApplier` is retained as the corpus's oracle and as the documented escape hatch @@ -91,6 +93,37 @@ not just corpus coverage: still carrying its `\ No newline at end of file` marker, followed by a kept line, is accepted by `git apply` (exit 0) but silently concatenates the two lines into one corrupt line. +### Post-verdict corrections (2026-07-06 stack review) + +The "0 divergences across 22 scenarios" claim above predates a high-effort stack review that +found two more divergence classes the original corpus missed. Both were fixed in place (in the +M2 changeset that introduced them) and are now pinned in the corpus/regression suite, so the +verdict — `Git2Applier` as the default write path — **stands**; these are corrections to the +evidence, not to the conclusion. + +1. **Exec-bit mode handling** (`git-workon-review/src/synthesis.rs`): `PatchText::to_bytes` + hardcoded `index 0000000..0000000 100644` on every synthesized patch. libgit2 takes the new + index entry's mode straight from this line, so staging any hunk of a `100755` file via + `Git2Applier` silently reset its mode to `100644` — a real divergence from `CliApplier`, which + reads the mode from the working tree and never had this bug. Fixed by threading the real mode + (`FileChange::old_mode`/`new_mode`, from `delta.{old,new}_file().mode()`) onto `PatchText` and + swapping it in `PatchText::invert`. Pinned by the `executable_whole_hunk_stage` corpus + scenario (table above) and by `synthesis.rs`'s own `whole_hunk_patch_carries_real_mode_into_index_line`/`invert_swaps_old_and_new_mode` + unit tests. +2. **Kept-EOFNL-deletion under `base == New`** (`git-workon-review/src/synthesis.rs`): a KEPT + deletion carrying `missing_newline: true`, followed by a dropped addition converted to context + (`base == New`'s drop rule), produced a hunk where the two backends actually DISAGREED rather + than merely diverging in end state: `CliApplier` accepted it and silently concatenated the + next line onto the no-newline deletion (the same class of corruption as the original trap-2 + finding); `Git2Applier` rejected the patch outright (`invalid patch hunk`). In this instance + git2 was the SAFE side — refusing a malformed patch is preferable to silently corrupting a + file — which is itself evidence for, not against, the `Git2Applier`-default verdict. Fixed by + extending the trap-2 splice (`splice_eofnl_context_lines`) to also rewrite a kept deletion's + own bytes (real trailing `\n`, marker dropped) when a later emitted line is context. Pinned by + `kept_eofnl_deletion_needs_splice_under_base_new` in `git-workon-review/tests/line_synthesis.rs` + (covers both backends via `Discard`); not duplicated into the corpus since that test already + exercises the identical fixture/selection/direction against both appliers end-to-end. + ## Milestones - **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-033](../adr/033-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help.