diff --git a/.github/workflows/call-build-upload.yml b/.github/workflows/call-build-upload.yml index 040c426..408560b 100644 --- a/.github/workflows/call-build-upload.yml +++ b/.github/workflows/call-build-upload.yml @@ -83,6 +83,7 @@ jobs: with: dry-run: ${{ env.ACT }} bin: ${{ inputs.package_name }} + features: jj ref: ${{ inputs.tag_ref }} target: ${{ matrix.target }} tar: all diff --git a/.github/workflows/call-cargo-publish.yml b/.github/workflows/call-cargo-publish.yml index f23db1a..0ff391e 100644 --- a/.github/workflows/call-cargo-publish.yml +++ b/.github/workflows/call-cargo-publish.yml @@ -33,6 +33,6 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - uses: dtolnay/rust-toolchain@stable - name: Publish to crates.io - run: cargo publish + run: cargo publish --all-features env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index a386ad6..7ce2046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bitflags" version = "2.10.0" @@ -144,6 +150,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -212,11 +227,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -320,9 +346,12 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clru" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "cocogitto" @@ -472,6 +501,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -613,6 +651,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -738,6 +777,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -807,6 +856,94 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzzy-matcher" version = "0.3.7" @@ -845,10 +982,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "git2" version = "0.20.4" @@ -1974,6 +2125,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2056,6 +2213,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "interim" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ce9099a85f468663d3225bf87e85d0548968441e1db12248b996b24f0f5b5a" +dependencies = [ + "chrono", + "logos", +] + [[package]] name = "io-close" version = "0.3.7" @@ -2139,6 +2306,65 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jj-lib" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f70302ae78e8dbb6aad7df472b3cdfb034649155cf8b7329240b6e79c38d659" +dependencies = [ + "async-trait", + "blake2", + "bstr", + "chrono", + "clru", + "digest", + "dunce", + "either", + "etcetera", + "futures", + "gix", + "globset", + "hashbrown 0.16.1", + "ignore", + "indexmap", + "interim", + "itertools", + "jj-lib-proc-macros", + "maplit", + "once_cell", + "pest", + "pest_derive", + "pollster", + "prost", + "rand 0.10.0", + "rand_chacha 0.10.0", + "rayon", + "ref-cast", + "regex", + "rustix 1.1.4", + "same-file", + "serde", + "smallvec", + "strsim", + "tempfile", + "thiserror", + "tokio", + "toml_edit 0.24.1+spec-1.1.0", + "tracing", + "winreg", +] + +[[package]] +name = "jj-lib-proc-macros" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8139c6755c9a8666ea01a75b4d817df838c8ceacd607f8e553b5cb4e9327836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2187,6 +2413,8 @@ dependencies = [ "gix", "indexmap", "inquire", + "jj-lib", + "pollster", "predicates", "rexpect", "serde", @@ -2209,6 +2437,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -2311,6 +2545,40 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "logos" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", +] + [[package]] name = "maplit" version = "1.0.2" @@ -2496,9 +2764,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -2506,9 +2774,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -2516,9 +2784,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -2529,9 +2797,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", @@ -2572,7 +2840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand", + "rand 0.8.5", ] [[package]] @@ -2593,12 +2861,24 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2662,11 +2942,21 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2682,6 +2972,29 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.42" @@ -2697,6 +3010,12 @@ 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.5" @@ -2704,8 +3023,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -2715,7 +3045,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.0", ] [[package]] @@ -2727,6 +3067,32 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2747,11 +3113,31 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2813,6 +3199,15 @@ dependencies = [ "ordered-multimap", ] +[[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 = "0.38.44" @@ -2938,9 +3333,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -2952,7 +3347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2973,7 +3368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3036,6 +3431,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "slug" version = "0.1.6" @@ -3051,6 +3452,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "stable_deref_trait" @@ -3083,6 +3487,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.111" @@ -3132,7 +3542,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -3218,6 +3628,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "pin-project-lite", +] + [[package]] name = "toml" version = "0.8.23" @@ -3227,7 +3647,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -3237,8 +3657,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] @@ -3254,9 +3674,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -3275,11 +3695,26 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_edit" +version = "0.24.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -3290,6 +3725,43 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3350,6 +3822,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.7" @@ -3417,7 +3895,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -3465,6 +3952,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "which" version = "4.4.2" @@ -3670,6 +4191,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -3682,6 +4213,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 5ed5ebd..0bd359e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,10 @@ documentation = "https://docs.rs/koji" repository = "https://github.com/cococonscious/koji" license = "MIT" +[features] +default = [] +jj = ["dep:jj-lib", "dep:pollster"] + [[bin]] name = "koji" path = "src/bin/main.rs" @@ -34,6 +38,10 @@ config = { version = "0.15", features = ["toml"] } xdg = "3.0" gix = "0.80.0" +# Optional jj support +jj-lib = { version = "0.39", optional = true } +pollster = { version = "0.4", optional = true } + [dev-dependencies] assert_cmd = "2.0.16" predicates = "3.1.2" diff --git a/README.md b/README.md index caac06f..6b2348a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ for automatic versioning, changelog generation, and more - Autocomplete for commit scope - Run as a git hook - Custom commit types +- Optional [jj (Jujutsu)](https://martinvonz.github.io/jj/) support ## Installation @@ -128,6 +129,28 @@ for the commit summary. Writing your commit as a conventional commit, e.g. `git commit -m "feat(space): delete some stars"`, will bypass koji altogether. +## Using with jj + +koji has optional support for [jj (Jujutsu)](https://martinvonz.github.io/jj/) repositories. +When built with the `jj` feature, koji can describe the working-copy change directly +instead of creating a git commit. + +### How it works + +- koji auto-detects whether you're in a jj or git repository +- Running `koji` in a jj repo updates the working-copy change's description +- If the working copy already has a conventional commit description, the prompts + are pre-filled so you can edit rather than start from scratch +- The `--hook` flag is not supported with jj (jj does not have commit hooks) + +### Installing with jj support + +Pre-built release binaries include jj support. If you're building from source: + +```bash +cargo install --locked koji --features jj +``` + ## Configuration Config values are prioritized in the following order: @@ -140,7 +163,8 @@ Config values are prioritized in the following order: - `~/.config/koji/config.toml` - Windows: - `%USERPROFILE%\AppData\Roaming\koji\config.toml` -- The [default](https://github.com/cococonscious/koji/blob/main/meta/config/default.toml) config +- The [default](https://github.com/cococonscious/koji/blob/main/meta/config/default.toml) + config ### Options @@ -148,15 +172,19 @@ Config values are prioritized in the following order: - Type: `bool` - Optional: `true` -- Description: Enables auto-complete for scope prompt via scanning commit history. +- Description: Enables auto-complete for scope prompt via + scanning commit history. + ```toml autocomplete = true ``` #### `breaking-changes` + - Type: `bool` - Optional: `true` - Description: Enables breaking change prompt. + ```toml breaking_changes = true ``` @@ -165,7 +193,9 @@ breaking_changes = true - Type: `Vec` - Optional: `true` -- Description: A list of commit types to use instead of the [default](https://github.com/cococonscious/koji/blob/main/meta/config/default.toml). +- Description: A list of commit types to use instead of the + [default](https://github.com/cococonscious/koji/blob/main/meta/config/default.toml). + ```toml [[commit_types]] name = "feat" @@ -177,7 +207,9 @@ description = "A new feature" - Type: `bool` - Optional: `true` -- Description: Prepend the commit summary with relevant emoji based on commit type. +- Description: Prepend the commit summary with relevant emoji + based on commit type. + ```toml emoji = true ``` @@ -186,8 +218,22 @@ emoji = true - Type: `bool` - Optional: `true` -- Description: Enables issue prompt, which will append a reference to an issue in the commit body. +- Description: Enables issue prompt, which will append a + reference to an issue in the commit body. + ```toml issues = true ``` +#### `vcs` + +- Type: `string` +- Optional: `true` +- Description: Controls which VCS backend to use. By default + (`"auto"`), koji auto-detects the repository type, preferring + jj when a `.jj/` directory is found. Set to `"git"` or `"jj"` + to force a specific backend. + +```toml +vcs = "auto" +``` diff --git a/meta/config/default.toml b/meta/config/default.toml index 8d0b072..cbe53db 100644 --- a/meta/config/default.toml +++ b/meta/config/default.toml @@ -4,6 +4,9 @@ issues = true emoji = false sign = false +# "auto", "git", or "jj" +vcs = "auto" + [[commit_types]] name = "feat" emoji = "✨" diff --git a/src/bin/main.rs b/src/bin/main.rs index ee3b42c..bdb9ef1 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,15 +1,15 @@ -use std::fs::read_to_string; use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use cocogitto::command::commit::CommitOptions; use conventional_commit_parser::parse; use koji::answers::{get_extracted_answers, ExtractedAnswers}; -use koji::commit::{commit, generate_commit_msg, write_commit_msg}; +use koji::commit::{commit, generate_commit_msg}; use koji::config::{Config, ConfigArgs}; -use koji::questions::{create_prompt, prompt_confirm}; -use koji::status::{check_staging, StagingStatus}; +use koji::questions::{create_prompt, prompt_confirm, PreviousAnswers}; +use koji::status::StagingStatus; +use koji::vcs::VcsBackend; #[derive(Parser, Debug)] #[command( @@ -121,8 +121,6 @@ enum SubCmds { #[cfg(not(tarpaulin_include))] fn main() -> Result<()> { - // Get CLI args - let Args { command, autocomplete, @@ -153,14 +151,39 @@ fn main() -> Result<()> { None => std::env::current_dir()?, }; - // Find repo - let repo = gix::discover(¤t_dir).context("could not find git repository")?; + let config = Config::new(Some(ConfigArgs { + autocomplete, + breaking_changes, + emoji, + issues, + path: config, + sign, + _user_config_path: None, + _current_dir: Some(current_dir.clone()), + }))?; + + let backend = VcsBackend::detect_with_hint(¤t_dir, config.vcs)?; - // Get existing commit message (passed in via `-m`) - let commit_editmsg = repo.path().join("COMMIT_EDITMSG"); - let commit_message = match read_to_string(commit_editmsg) { - Ok(contents) => contents.lines().next().unwrap_or("").to_string(), - Err(_) => "".to_string(), + if hook && !backend.supports_hooks() { + anyhow::bail!("--hook mode is not supported with jj repositories (jj has no commit hooks)"); + } + + let commit_message = if hook { + backend + .read_current_description()? + .map(|c| c.lines().next().unwrap_or("").to_string()) + .unwrap_or_default() + } else { + String::new() + }; + + // For jj, parse the existing description to pre-populate prompts + let previous_answers = if !hook && backend.is_jj() { + backend + .read_current_description()? + .and_then(|desc| PreviousAnswers::from_description(&desc)) + } else { + None }; if hook && parse(&commit_message).is_ok() { @@ -169,7 +192,7 @@ fn main() -> Result<()> { // --hook and --stdout don't create commits; --all stages tracked files automatically if !hook && !stdout && !all { - match check_staging(&repo)? { + match backend.check_staging()? { StagingStatus::Empty => { anyhow::bail!("no files staged for commit"); } @@ -183,22 +206,8 @@ fn main() -> Result<()> { } } - // Load config - let config = Config::new(Some(ConfigArgs { - autocomplete, - breaking_changes, - emoji, - issues, - path: config, - sign, - _user_config_path: None, - _current_dir: Some(current_dir.clone()), - }))?; - - // Get answers from interactive prompt - let answers = create_prompt(commit_message, &config)?; + let answers = create_prompt(previous_answers, &config, &backend)?; - // Get data necessary for a conventional commit let ExtractedAnswers { body, commit_type, @@ -207,7 +216,6 @@ fn main() -> Result<()> { summary, } = get_extracted_answers(answers, config.emoji, &config.commit_types)?; - // Generate the commit message let message = generate_commit_msg( commit_type.clone(), scope.clone(), @@ -216,27 +224,24 @@ fn main() -> Result<()> { is_breaking_change, )?; - // Print the commit message preview if stdout { println!("{message}"); } else { eprintln!("\n{message}\n"); } - // --stdout just prints the message without committing if stdout { return Ok(()); } - // Prompt for confirmation unless --yes is set if !yes && !prompt_confirm()? { eprintln!("Commit aborted."); return Ok(()); } // Do the thing! - if hook { - write_commit_msg(&repo, commit_type, scope, summary, body, is_breaking_change)?; + if hook || backend.is_jj() { + backend.write_commit_msg(commit_type, scope, summary, body, is_breaking_change)?; } else { let options = CommitOptions { commit_type: commit_type.as_str(), diff --git a/src/lib/commit.rs b/src/lib/commit.rs index f4d0faa..cd25f29 100644 --- a/src/lib/commit.rs +++ b/src/lib/commit.rs @@ -1,9 +1,8 @@ -use std::{fs::File, io::Write, path::PathBuf}; +use std::path::PathBuf; use anyhow::Result; use cocogitto::command::commit::CommitOptions; use cocogitto::CocoGitto; -use gix::Repository; /// Generates the commit message pub fn generate_commit_msg( @@ -25,25 +24,6 @@ pub fn generate_commit_msg( Ok(message) } -/// Output a commit message to `.git/COMMIT_EDITMSG` -pub fn write_commit_msg( - repo: &Repository, - commit_type: String, - scope: Option, - summary: String, - body: Option, - is_breaking_change: bool, -) -> Result<()> { - let message = generate_commit_msg(commit_type, scope, summary, body, is_breaking_change)?; - - let commit_editmsg = repo.path().join("COMMIT_EDITMSG"); - let mut file = File::create(commit_editmsg)?; - - file.write_all(message.as_bytes())?; - - Ok(()) -} - /// Create a commit pub fn commit(current_dir: PathBuf, options: CommitOptions) -> Result<()> { // Set config path before creating CocoGitto instance (required in 6.4.0+) diff --git a/src/lib/config.rs b/src/lib/config.rs index 0ca30c6..b04e4d3 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -8,6 +8,14 @@ use std::path::PathBuf; #[cfg(any(unix, target_os = "redox"))] use xdg::BaseDirectories; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VcsPreference { + Auto, + Git, + Jj, +} + #[derive(Debug, Clone)] pub struct Config { pub autocomplete: bool, @@ -17,6 +25,7 @@ pub struct Config { pub issues: bool, pub sign: bool, pub workdir: PathBuf, + pub vcs: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] @@ -35,6 +44,7 @@ struct ConfigTOML { pub emoji: bool, pub issues: bool, pub sign: bool, + pub vcs: Option, } #[derive(Default)] @@ -50,7 +60,6 @@ pub struct ConfigArgs { } impl Config { - /// Find a config and load it pub fn new(args: Option) -> Result { let ConfigArgs { path, @@ -109,6 +118,7 @@ impl Config { issues: issues.unwrap_or(config.issues), sign: sign.unwrap_or(config.sign), workdir, + vcs: config.vcs, }) } } diff --git a/src/lib/lib.rs b/src/lib/lib.rs index 3a8daa1..482dde6 100644 --- a/src/lib/lib.rs +++ b/src/lib/lib.rs @@ -4,3 +4,4 @@ pub mod config; pub mod emoji; pub mod questions; pub mod status; +pub mod vcs; diff --git a/src/lib/questions.rs b/src/lib/questions.rs index 841e442..a55b9e1 100644 --- a/src/lib/questions.rs +++ b/src/lib/questions.rs @@ -1,7 +1,6 @@ use crate::config::{CommitType, Config}; -use anyhow::{Context, Result}; -use conventional_commit_parser::parse_summary; -use gix::bstr::ByteSlice; +use crate::vcs::VcsBackend; +use anyhow::Result; use indexmap::IndexMap; use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet}; use inquire::{ @@ -10,6 +9,58 @@ use inquire::{ Confirm, CustomUserError, Select, Text, }; +#[derive(Debug, Clone, Default)] +pub struct PreviousAnswers { + pub commit_type: Option, + pub scope: Option, + pub summary: Option, + pub body: Option, + pub is_breaking_change: bool, + pub breaking_change_text: Option, + pub issue_footer: Option, +} + +impl PreviousAnswers { + /// Parse an existing conventional commit description into pre-populated answers. + /// + /// Returns `None` if the description cannot be parsed as a conventional commit. + pub fn from_description(desc: &str) -> Option { + let parsed = conventional_commit_parser::parse(desc).ok()?; + + let breaking_pos = parsed.footers.iter().position(|f| f.is_breaking_change()); + + let issue_footer_entry = match breaking_pos { + Some(pos) if pos > 0 => Some(&parsed.footers[pos - 1]), + Some(_) => None, + None => parsed.footers.last(), + } + .filter(|f| !f.is_breaking_change()); + + Some(Self { + commit_type: Some(parsed.commit_type.as_ref().to_string()), + scope: parsed.scope, + summary: Some(parsed.summary), + body: parsed.body, + is_breaking_change: parsed.is_breaking_change, + breaking_change_text: parsed + .footers + .iter() + .find(|f| f.is_breaking_change()) + .map(|f| f.content.clone()), + issue_footer: issue_footer_entry.map(|f| { + if matches!( + f.token_separator, + conventional_commit_parser::commit::Separator::Hash + ) { + format!("{} #{}", f.token, f.content) + } else { + format!("{}: {}", f.token, f.content) + } + }), + }) + } +} + fn get_skip_hint() -> &'static str { " or to skip" } @@ -22,12 +73,10 @@ fn get_render_config() -> RenderConfig<'static> { } } -/// Transform commit type choice fn transform_commit_type_choice(choice: &str) -> String { choice.split(':').next().unwrap().into() } -/// Format the commit type choices fn format_commit_type_choice( use_emoji: bool, commit_type: &CommitType, @@ -69,13 +118,23 @@ fn validate_issue_reference(input: &str) -> Result } } -fn prompt_type(config: &Config) -> Result { - let type_values = config +fn prompt_type(config: &Config, previous_type: Option<&str>) -> Result { + let mut type_values: Vec = config .commit_types .iter() .map(|(_, choice)| format_commit_type_choice(config.emoji, choice, &config.commit_types)) .collect(); + if let Some(prev) = previous_type { + if let Some(pos) = type_values + .iter() + .position(|v| transform_commit_type_choice(v) == prev) + { + let item = type_values.remove(pos); + type_values.insert(0, item); + } + } + let selected_type = Select::new("What type of change are you committing?", type_values) .with_render_config(get_render_config()) .with_formatter(&|v| transform_commit_type_choice(v.value)) @@ -86,51 +145,20 @@ fn prompt_type(config: &Config) -> Result { #[derive(Debug, Clone)] struct ScopeAutocompleter { - config: Config, + scopes: Vec, } impl ScopeAutocompleter { - fn get_existing_scopes(&self) -> Result> { - let repo = gix::discover(&self.config.workdir).context("could not find git repository")?; - - let head_id = repo.head_id().context("could not get HEAD")?; - - let walk = - repo.rev_walk([head_id.detach()]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )); - - let mut scopes: Vec = Vec::new(); - - for info in walk.all()? { - let info = info?; - - let commit = repo.find_commit(info.id)?; - - let message = commit.message()?; - - let summary = message.summary(); - - // Parse the summary - ignore errors for invalid commit messages - if let Ok(parsed) = parse_summary(summary.to_str()?) { - if let Some(scope) = parsed.scope { - if !scopes.contains(&scope) { - scopes.push(scope); - } - } - } - } - - Ok(scopes) + fn new(backend: &VcsBackend) -> Self { + let scopes = backend.commit_scopes().unwrap_or_default(); + Self { scopes } } } impl Autocomplete for ScopeAutocompleter { fn get_suggestions(&mut self, input: &str) -> Result, CustomUserError> { - let existing_scopes = self.get_existing_scopes().unwrap_or_default(); - - Ok(existing_scopes + Ok(self + .scopes .iter() .filter(|s| s.contains(input)) .cloned() @@ -147,20 +175,21 @@ impl Autocomplete for ScopeAutocompleter { } } -fn prompt_scope(config: &Config) -> Result> { - let mut scope_autocompleter = ScopeAutocompleter { - config: config.clone(), +fn prompt_scope( + config: &Config, + backend: &VcsBackend, + previous_scope: Option<&str>, +) -> Result> { + let scope_autocompleter = ScopeAutocompleter::new(backend); + let help_message = if config.autocomplete && !scope_autocompleter.scopes.is_empty() { + format!( + "{}, {}", + "↑↓ to move, tab to autocomplete, enter to submit", + get_skip_hint() + ) + } else { + get_skip_hint().to_string() }; - let help_message = - if config.autocomplete && !scope_autocompleter.get_suggestions("").unwrap().is_empty() { - format!( - "{}, {}", - "↑↓ to move, tab to autocomplete, enter to submit", - get_skip_hint() - ) - } else { - get_skip_hint().to_string() - }; let mut selected_scope = Text::new("What's the scope of this change?") .with_render_config(RenderConfig { @@ -169,6 +198,10 @@ fn prompt_scope(config: &Config) -> Result> { }) .with_help_message(help_message.as_str()); + if let Some(prev) = previous_scope { + selected_scope = selected_scope.with_initial_value(prev); + } + if config.autocomplete { selected_scope = selected_scope.with_autocomplete(scope_autocompleter); } @@ -183,28 +216,39 @@ fn prompt_scope(config: &Config) -> Result> { } } -fn prompt_summary(msg: String) -> Result { - let previous_summary = match parse_summary(&msg) { - Ok(parsed) => parsed.summary, - Err(_) => "".into(), - }; - - let summary = Text::new("Write a short, imperative tense description of the change:") +fn prompt_summary(previous_summary: Option<&str>) -> Result { + let mut prompt = Text::new("Write a short, imperative tense description of the change:") .with_render_config(get_render_config()) - .with_placeholder(&previous_summary) - .with_validator(validate_summary) - .prompt()?; + .with_validator(validate_summary); + + if let Some(prev) = previous_summary { + if !prev.is_empty() { + prompt = prompt.with_initial_value(prev); + } + } + + let summary = prompt.prompt()?; Ok(summary) } -fn prompt_body() -> Result> { +fn prompt_body(previous_body: Option<&str>) -> Result> { let help_message = format!("{}, {}", "Use '\\n' for newlines", get_skip_hint()); - let summary = Text::new("Provide a longer description of the change:") + // Pre-fill: convert real newlines back to \\n for display in the single-line input + let escaped_body = previous_body + .filter(|p| !p.is_empty()) + .map(|p| p.replace('\n', "\\n")); + + let mut prompt = Text::new("Provide a longer description of the change:") .with_render_config(get_render_config()) - .with_help_message(help_message.as_str()) - .prompt_skippable()?; + .with_help_message(help_message.as_str()); + + if let Some(ref escaped) = escaped_body { + prompt = prompt.with_initial_value(escaped); + } + + let summary = prompt.prompt_skippable()?; if let Some(summary) = summary { if summary.is_empty() { @@ -216,22 +260,31 @@ fn prompt_body() -> Result> { } } -fn prompt_breaking() -> Result { +fn prompt_breaking(previous_breaking: bool) -> Result { let answer = Confirm::new("Are there any breaking changes?") .with_render_config(get_render_config()) - .with_default(false) + .with_default(previous_breaking) .prompt()?; Ok(answer) } -fn prompt_breaking_text() -> Result> { +fn prompt_breaking_text(previous_text: Option<&str>) -> Result> { let help_message = format!("{}, {}", "Use '\\n' for newlines", get_skip_hint()); - let breaking_text = Text::new("Describe the breaking changes in detail:") + let escaped_text = previous_text + .filter(|p| !p.is_empty()) + .map(|p| p.replace('\n', "\\n")); + + let mut prompt = Text::new("Describe the breaking changes in detail:") .with_render_config(get_render_config()) - .with_help_message(help_message.as_str()) - .prompt_skippable()?; + .with_help_message(help_message.as_str()); + + if let Some(ref escaped) = escaped_text { + prompt = prompt.with_initial_value(escaped); + } + + let breaking_text = prompt.prompt_skippable()?; if let Some(breaking_text) = breaking_text { if breaking_text.is_empty() { @@ -243,21 +296,28 @@ fn prompt_breaking_text() -> Result> { } } -fn prompt_issues() -> Result { +fn prompt_issues(has_previous_issue: bool) -> Result { let answer = Confirm::new("Does this change affect any open issues?") .with_render_config(get_render_config()) - .with_default(false) + .with_default(has_previous_issue) .prompt()?; Ok(answer) } -fn prompt_issue_text() -> Result { - let summary = Text::new("Add the issue reference:") +fn prompt_issue_text(previous_issue: Option<&str>) -> Result { + let mut prompt = Text::new("Add the issue reference:") .with_render_config(get_render_config()) .with_help_message("e.g. \"closes #123\"") - .with_validator(validate_issue_reference) - .prompt()?; + .with_validator(validate_issue_reference); + + if let Some(prev) = previous_issue { + if !prev.is_empty() { + prompt = prompt.with_initial_value(prev); + } + } + + let summary = prompt.prompt()?; Ok(summary) } @@ -274,24 +334,30 @@ pub struct Answers { } /// Create the interactive prompt -pub fn create_prompt(last_message: String, config: &Config) -> Result { - let commit_type = prompt_type(config)?; - let scope = prompt_scope(config)?; - let summary = prompt_summary(last_message)?; - let body = prompt_body()?; +pub fn create_prompt( + previous: Option, + config: &Config, + backend: &VcsBackend, +) -> Result { + let prev = previous.unwrap_or_default(); + + let commit_type = prompt_type(config, prev.commit_type.as_deref())?; + let scope = prompt_scope(config, backend, prev.scope.as_deref())?; + let summary = prompt_summary(prev.summary.as_deref())?; + let body = prompt_body(prev.body.as_deref())?; let mut breaking = false; let mut breaking_footer: Option = None; if config.breaking_changes { - breaking = prompt_breaking()?; + breaking = prompt_breaking(prev.is_breaking_change)?; if breaking { - breaking_footer = prompt_breaking_text()?; + breaking_footer = prompt_breaking_text(prev.breaking_change_text.as_deref())?; } } let mut issue_footer = None; - if config.issues && prompt_issues()? { - issue_footer = Some(prompt_issue_text()?); + if config.issues && prompt_issues(prev.issue_footer.is_some())? { + issue_footer = Some(prompt_issue_text(prev.issue_footer.as_deref())?); } Ok(Answers { @@ -410,4 +476,123 @@ mod tests { "An issue reference is required".into() ))); } + + #[test] + fn test_from_description_simple() { + let result = PreviousAnswers::from_description("feat: add user login"); + + let answers = result.expect("should parse a simple conventional commit"); + assert_eq!(answers.commit_type, Some("feat".into())); + assert_eq!(answers.scope, None); + assert_eq!(answers.summary, Some("add user login".into())); + assert_eq!(answers.body, None); + assert!(!answers.is_breaking_change); + assert_eq!(answers.breaking_change_text, None); + assert_eq!(answers.issue_footer, None); + } + + #[test] + fn test_from_description_with_scope() { + let result = PreviousAnswers::from_description("fix(parser): handle edge case"); + + let answers = result.expect("should parse commit with scope"); + assert_eq!(answers.commit_type, Some("fix".into())); + assert_eq!(answers.scope, Some("parser".into())); + assert_eq!(answers.summary, Some("handle edge case".into())); + } + + #[test] + fn test_from_description_with_body() { + let desc = "feat: add login\n\nThis adds a complete login flow with OAuth support."; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse commit with body"); + assert_eq!(answers.commit_type, Some("feat".into())); + assert_eq!(answers.summary, Some("add login".into())); + assert_eq!( + answers.body, + Some("This adds a complete login flow with OAuth support.".into()) + ); + } + + #[test] + fn test_from_description_with_breaking_change_footer() { + let desc = + "feat!: remove deprecated API\n\nBREAKING CHANGE: The /v1 endpoints have been removed"; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse commit with breaking change"); + assert!(answers.is_breaking_change); + assert_eq!( + answers.breaking_change_text, + Some("The /v1 endpoints have been removed".into()) + ); + } + + #[test] + fn test_from_description_with_breaking_change_bang_only() { + let result = PreviousAnswers::from_description("feat!: remove deprecated API"); + + let answers = result.expect("should parse breaking change with bang"); + assert!(answers.is_breaking_change); + assert_eq!(answers.breaking_change_text, None); + } + + #[test] + fn test_from_description_with_issue_footer() { + let desc = "fix: resolve crash\n\nCloses #456"; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse commit with issue footer"); + assert_eq!(answers.issue_footer, Some("Closes #456".into())); + } + + #[test] + fn test_from_description_with_issue_and_breaking_footers() { + let desc = "feat!: overhaul auth\n\nRefs #789\nBREAKING CHANGE: Token format changed"; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse commit with both footers"); + assert!(answers.is_breaking_change); + assert_eq!( + answers.breaking_change_text, + Some("Token format changed".into()) + ); + assert_eq!(answers.issue_footer, Some("Refs #789".into())); + } + + #[test] + fn test_from_description_with_colon_separator_footer() { + let desc = "fix: resolve crash\n\nCloses: 456"; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse commit with colon-separated footer"); + assert_eq!(answers.issue_footer, Some("Closes: 456".into())); + } + + #[test] + fn test_from_description_invalid_message() { + let result = PreviousAnswers::from_description("not a conventional commit"); + assert!(result.is_none()); + } + + #[test] + fn test_from_description_empty_string() { + let result = PreviousAnswers::from_description(""); + assert!(result.is_none()); + } + + #[test] + fn test_from_description_breaking_footer_only_no_issue() { + let desc = "feat!: drop support\n\nBREAKING CHANGE: Removed Python 2 support"; + let result = PreviousAnswers::from_description(desc); + + let answers = result.expect("should parse breaking-only footer"); + assert!(answers.is_breaking_change); + assert_eq!( + answers.breaking_change_text, + Some("Removed Python 2 support".into()) + ); + assert_eq!(answers.issue_footer, None); + } } diff --git a/src/lib/status.rs b/src/lib/status.rs index ba644d5..91165c9 100644 --- a/src/lib/status.rs +++ b/src/lib/status.rs @@ -1,57 +1,6 @@ -use std::convert::Infallible; - -use anyhow::{Context, Result}; -use gix::Repository; - #[derive(Debug, PartialEq, Eq)] pub enum StagingStatus { Empty, Partial { staged: usize, unstaged: usize }, Ready { staged: usize }, } - -/// Compares HEAD tree vs index (staged) and index vs worktree (unstaged). -/// Uses an empty tree as the baseline for initial commits. -pub fn check_staging(repo: &Repository) -> Result { - let index = repo.index_or_empty().context("could not read index")?; - let head_tree_id = repo - .head_tree_id_or_empty() - .context("could not resolve HEAD tree")?; - - let mut staged_count: usize = 0; - repo.tree_index_status( - &head_tree_id, - &index, - None, - gix::status::tree_index::TrackRenames::Disabled, - |_, _, _| { - staged_count += 1; - Ok::<_, Infallible>(gix::diff::index::Action::Continue(())) - }, - ) - .context("could not diff HEAD tree against index")?; - - let mut unstaged_count: usize = 0; - let status_iter = repo - .status(gix::progress::Discard) - .context("could not initialize status")? - .index_worktree_options_mut(|opts| { - opts.dirwalk_options = None; // only tracked files, not untracked - }) - .into_index_worktree_iter(Vec::::new()) - .context("could not iterate worktree status")?; - - for entry in status_iter { - entry.context("error reading worktree status entry")?; - unstaged_count += 1; - } - - match (staged_count, unstaged_count) { - (0, _) => Ok(StagingStatus::Empty), - (s, 0) => Ok(StagingStatus::Ready { staged: s }), - (s, u) => Ok(StagingStatus::Partial { - staged: s, - unstaged: u, - }), - } -} diff --git a/src/lib/vcs.rs b/src/lib/vcs.rs new file mode 100644 index 0000000..fd1454e --- /dev/null +++ b/src/lib/vcs.rs @@ -0,0 +1,385 @@ +use std::convert::Infallible; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use conventional_commit_parser::parse_summary; +use gix::bstr::ByteSlice; + +use crate::config::VcsPreference; +use crate::status::StagingStatus; + +pub enum VcsBackend { + Git { + repo: Box, + }, + #[cfg(feature = "jj")] + Jj { + workspace_root: PathBuf, + }, +} + +/// Dispatch on the VcsBackend enum, hiding the per-arm `#[cfg(feature = "jj")]`. +macro_rules! match_backend { + ($self:expr, Git { $($g:tt)* } => $git:expr, Jj { $($j:tt)* } => $jj:expr $(,)?) => { + match $self { + Self::Git { $($g)* } => $git, + #[cfg(feature = "jj")] + Self::Jj { $($j)* } => $jj, + } + }; +} + +impl VcsBackend { + /// Detection priority (when auto): + /// 1. If `jj` feature enabled and `.jj/` exists -> Jj backend + /// 2. If `.git/` exists -> Git backend + /// 3. Error: no VCS found + pub fn detect_with_hint(path: &Path, vcs: Option) -> Result { + match vcs { + Some(VcsPreference::Git) => return Self::open_git(path), + #[cfg(feature = "jj")] + Some(VcsPreference::Jj) => { + let mut current = Some(path); + while let Some(dir) = current { + if dir.join(".jj").is_dir() { + return Self::open_jj(dir); + } + current = dir.parent(); + } + anyhow::bail!("vcs is set to \"jj\" but no .jj/ directory found"); + } + #[cfg(not(feature = "jj"))] + Some(VcsPreference::Jj) => { + anyhow::bail!( + "vcs is set to \"jj\" but koji was not compiled with jj support (enable the 'jj' feature)" + ); + } + Some(VcsPreference::Auto) | None => {} + } + + #[cfg(feature = "jj")] + { + let mut current = Some(path); + while let Some(dir) = current { + if dir.join(".jj").is_dir() { + return Self::open_jj(dir); + } + current = dir.parent(); + } + } + + let jj_hint = if cfg!(feature = "jj") { " or jj" } else { "" }; + let ctx = format!("could not find a supported repository (git{jj_hint})"); + + let result = Self::open_git(path).context(ctx); + + #[cfg(not(feature = "jj"))] + if result.is_err() { + let mut current = Some(path); + while let Some(dir) = current { + if dir.join(".jj").is_dir() { + anyhow::bail!( + "found a .jj/ directory but koji was not compiled with jj support" + ); + } + current = dir.parent(); + } + } + + result + } + + fn open_git(path: &Path) -> Result { + let repo = gix::discover(path).context("could not find git repository")?; + Ok(Self::Git { + repo: Box::new(repo), + }) + } + + #[cfg(feature = "jj")] + fn open_jj(path: &Path) -> Result { + use anyhow::anyhow; + + let jj_dir = path.join(".jj"); + if !jj_dir.is_dir() { + return Err(anyhow!("no .jj directory found at {}", path.display())); + } + + Ok(Self::Jj { + workspace_root: path.to_path_buf(), + }) + } + + pub fn supports_hooks(&self) -> bool { + match_backend!(self, Git { .. } => true, Jj { .. } => false) + } + + pub fn is_jj(&self) -> bool { + match_backend!(self, Git { .. } => false, Jj { .. } => true) + } + + pub fn root_path(&self) -> PathBuf { + match_backend!( + self, + Git { repo } => repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(), + Jj { workspace_root, .. } => workspace_root.clone(), + ) + } + + /// For git, reads `COMMIT_EDITMSG`. For jj, reads the working copy's description. + pub fn read_current_description(&self) -> Result> { + match_backend!( + self, + Git { repo } => { + let msg_path = repo.path().join("COMMIT_EDITMSG"); + if msg_path.exists() { + let content = std::fs::read_to_string(&msg_path)?; + Ok(Some(content)) + } else { + Ok(None) + } + }, + Jj { workspace_root } => Self::jj_read_current_description(workspace_root), + ) + } + + /// For git, writes to `COMMIT_EDITMSG`. For jj, describes the current change via jj-lib. + pub fn write_commit_msg( + &self, + commit_type: String, + scope: Option, + summary: String, + body: Option, + is_breaking_change: bool, + ) -> Result<()> { + let message = crate::commit::generate_commit_msg( + commit_type, + scope, + summary, + body, + is_breaking_change, + )?; + match_backend!( + self, + Git { repo } => { + let commit_editmsg = repo.path().join("COMMIT_EDITMSG"); + let mut file = std::fs::File::create(commit_editmsg)?; + std::io::Write::write_all(&mut file, message.as_bytes())?; + Ok(()) + }, + Jj { workspace_root } => Self::jj_write_description(workspace_root, &message), + ) + } + + pub fn commit_scopes(&self) -> Result> { + match_backend!( + self, + Git { repo } => { + let head_id = repo.head_id().context("could not get HEAD")?; + + let walk = repo.rev_walk([head_id.detach()]).sorting( + gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + ), + ); + + let mut scopes: Vec = Vec::new(); + + for info in walk.all()? { + let info = info?; + let commit = repo.find_commit(info.id)?; + let message = commit.message()?; + let summary = message.summary(); + + if let Ok(parsed) = parse_summary(summary.to_str()?) { + if let Some(scope) = parsed.scope { + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + } + } + + Ok(scopes) + }, + Jj { workspace_root } => Self::jj_commit_scopes(workspace_root), + ) + } + + /// For jj, always returns `Ready` since the working copy is always a commit. + pub fn check_staging(&self) -> Result { + match_backend!( + self, + Git { repo } => { + let index = repo.index_or_empty().context("could not read index")?; + let head_tree_id = repo + .head_tree_id_or_empty() + .context("could not resolve HEAD tree")?; + + let mut staged_count: usize = 0; + repo.tree_index_status( + &head_tree_id, + &index, + None, + gix::status::tree_index::TrackRenames::Disabled, + |_, _, _| { + staged_count += 1; + Ok::<_, Infallible>(gix::diff::index::Action::Continue(())) + }, + ) + .context("could not diff HEAD tree against index")?; + + let mut unstaged_count: usize = 0; + let status_iter = repo + .status(gix::progress::Discard) + .context("could not initialize status")? + .index_worktree_options_mut(|opts| { + opts.dirwalk_options = None; // only tracked files, not untracked + }) + .into_index_worktree_iter(Vec::::new()) + .context("could not iterate worktree status")?; + + for entry in status_iter { + entry.context("error reading worktree status entry")?; + unstaged_count += 1; + } + + match (staged_count, unstaged_count) { + (0, _) => Ok(StagingStatus::Empty), + (s, 0) => Ok(StagingStatus::Ready { staged: s }), + (s, u) => Ok(StagingStatus::Partial { + staged: s, + unstaged: u, + }), + } + }, + Jj { .. } => Ok(StagingStatus::Ready { staged: 0 }), + ) + } + + // ---- jj-specific implementations ---- + + #[cfg(feature = "jj")] + fn jj_load_repo( + workspace_root: &Path, + ) -> Result<( + std::sync::Arc, + jj_lib::ref_name::WorkspaceNameBuf, + )> { + use jj_lib::config::StackedConfig; + use jj_lib::repo::StoreFactories; + use jj_lib::settings::UserSettings; + use jj_lib::workspace::{default_working_copy_factories, Workspace}; + use pollster::FutureExt as _; + + let config = StackedConfig::with_defaults(); + let settings = + UserSettings::from_config(config).context("could not create jj UserSettings")?; + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + + let workspace = Workspace::load(&settings, workspace_root, &store_factories, &wc_factories) + .context("could not load jj workspace")?; + let workspace_name = workspace.workspace_name().to_owned(); + + let repo = workspace + .repo_loader() + .load_at_head() + .block_on() + .context("could not load jj repo at head")?; + + Ok((repo, workspace_name)) + } + + #[cfg(feature = "jj")] + fn jj_wc_commit( + repo: &std::sync::Arc, + workspace_name: &jj_lib::ref_name::WorkspaceName, + ) -> Result { + use jj_lib::repo::Repo as _; + + let wc_commit_id = repo + .view() + .get_wc_commit_id(workspace_name) + .context("no working copy commit found for current workspace")?; + + let commit = repo + .store() + .get_commit(wc_commit_id) + .context("could not get working copy commit")?; + + Ok(commit) + } + + #[cfg(feature = "jj")] + fn jj_read_current_description(workspace_root: &Path) -> Result> { + let (repo, workspace_name) = Self::jj_load_repo(workspace_root)?; + let commit = Self::jj_wc_commit(&repo, &workspace_name)?; + let desc = commit.description().to_string(); + if desc.trim().is_empty() { + Ok(None) + } else { + Ok(Some(desc)) + } + } + + #[cfg(feature = "jj")] + fn jj_write_description(workspace_root: &Path, message: &str) -> Result<()> { + use pollster::FutureExt as _; + + let (repo, workspace_name) = Self::jj_load_repo(workspace_root)?; + let commit = Self::jj_wc_commit(&repo, &workspace_name)?; + + let mut tx = repo.start_transaction(); + tx.repo_mut() + .rewrite_commit(&commit) + .set_description(message) + .write() + .block_on() + .context("could not write jj commit description")?; + tx.repo_mut() + .rebase_descendants() + .block_on() + .context("could not rebase descendants after jj describe")?; + tx.commit("koji: describe change") + .block_on() + .context("could not commit jj transaction")?; + + Ok(()) + } + + #[cfg(feature = "jj")] + fn jj_commit_scopes(workspace_root: &Path) -> Result> { + use jj_lib::repo::Repo as _; + use jj_lib::revset::{ResolvedRevsetExpression, RevsetIteratorExt as _}; + + let (repo, workspace_name) = Self::jj_load_repo(workspace_root)?; + let commit = Self::jj_wc_commit(&repo, &workspace_name)?; + + let ancestors_expr = + ResolvedRevsetExpression::commits(vec![commit.id().clone()]).ancestors(); + let revset = ancestors_expr + .evaluate(repo.as_ref()) + .context("could not evaluate jj revset")?; + + let mut scopes: Vec = Vec::new(); + + for commit_result in revset.iter().commits(repo.store()) { + let ancestor = match commit_result { + Ok(c) => c, + Err(_) => break, + }; + let desc = ancestor.description().to_string(); + let first_line = desc.lines().next().unwrap_or(""); + if let Ok(parsed) = parse_summary(first_line) { + if let Some(scope) = parsed.scope { + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + } + } + + Ok(scopes) + } +} diff --git a/tests/integration.rs b/tests/integration.rs index f8504f8..93fa3bb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,4 +1,6 @@ -use git2::{Commit, IndexAddOption, Oid, Repository, RepositoryInitOptions}; +#[cfg(not(target_os = "windows"))] +use git2::Commit; +use git2::{IndexAddOption, Oid, Repository, RepositoryInitOptions}; #[cfg(not(target_os = "windows"))] use rexpect::{ process::wait, @@ -765,3 +767,472 @@ fn test_confirmation_decline() -> Result<(), Box> { Ok(()) } + +// ---- Jj integration tests ---- + +#[cfg(feature = "jj")] +mod jj_tests { + use super::*; + use std::path::Path; + + use jj_lib::config::StackedConfig; + use jj_lib::repo::{ReadonlyRepo, Repo as _, StoreFactories}; + use jj_lib::settings::UserSettings; + use jj_lib::workspace::{ + default_working_copy_factories, default_working_copy_factory, Workspace, + }; + use pollster::FutureExt as _; + use std::sync::Arc; + + fn jj_settings() -> UserSettings { + let config = StackedConfig::with_defaults(); + UserSettings::from_config(config).expect("could not create jj UserSettings") + } + + /// Create a non-colocated jj repo in the given directory. + fn create_jj_repo(dir: &Path) { + let settings = jj_settings(); + Workspace::init_internal_git(&settings, dir) + .block_on() + .expect("failed to init non-colocated jj repo"); + } + + /// Create a colocated jj repo (both `.jj/` and `.git/` exist). + fn create_colocated_jj_repo(dir: &Path) { + let settings = jj_settings(); + Workspace::init_colocated_git(&settings, dir) + .block_on() + .expect("failed to init colocated jj repo"); + } + + fn jj_load_repo( + workspace_root: &Path, + ) -> (Arc, jj_lib::ref_name::WorkspaceNameBuf) { + let settings = jj_settings(); + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + let workspace = Workspace::load(&settings, workspace_root, &store_factories, &wc_factories) + .expect("could not load jj workspace"); + let workspace_name = workspace.workspace_name().to_owned(); + let repo = workspace + .repo_loader() + .load_at_head() + .block_on() + .expect("could not load jj repo at head"); + (repo, workspace_name) + } + + fn jj_wc_commit( + repo: &Arc, + workspace_name: &jj_lib::ref_name::WorkspaceName, + ) -> jj_lib::commit::Commit { + let wc_commit_id = repo + .view() + .get_wc_commit_id(workspace_name) + .expect("no working copy commit found"); + repo.store() + .get_commit(wc_commit_id) + .expect("could not get working copy commit") + } + + /// Get the current `@` commit description via jj-lib. + fn jj_get_description(dir: &Path) -> String { + let (repo, workspace_name) = jj_load_repo(dir); + let commit = jj_wc_commit(&repo, &workspace_name); + commit.description().trim().to_string() + } + + /// Describe the current `@` commit (for pre-population tests). + fn jj_describe(dir: &Path, message: &str) { + let (repo, workspace_name) = jj_load_repo(dir); + let commit = jj_wc_commit(&repo, &workspace_name); + let mut tx = repo.start_transaction(); + tx.repo_mut() + .rewrite_commit(&commit) + .set_description(message) + .write() + .block_on() + .expect("could not write jj commit description"); + tx.repo_mut() + .rebase_descendants() + .block_on() + .expect("could not rebase descendants"); + tx.commit("test: describe change") + .block_on() + .expect("could not commit jj transaction"); + } + + fn create_jj_workspace(existing_workspace_root: &Path, new_workspace_root: &Path, name: &str) { + std::fs::create_dir_all(new_workspace_root).expect("failed to create workspace directory"); + + let settings = jj_settings(); + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + let existing_workspace = Workspace::load( + &settings, + existing_workspace_root, + &store_factories, + &wc_factories, + ) + .expect("could not load existing workspace"); + let repo = existing_workspace + .repo_loader() + .load_at_head() + .block_on() + .expect("could not load repo at head"); + + Workspace::init_workspace_with_existing_repo( + new_workspace_root, + existing_workspace.repo_path(), + &repo, + &*default_working_copy_factory(), + name.into(), + ) + .block_on() + .expect("failed to create workspace"); + } + + // ---- Non-interactive tests ---- + + #[test] + fn test_jj_repo_detection() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + + create_jj_repo(temp_dir.path()); + + let output = Command::new(&bin_path) + .arg("-C") + .arg(temp_dir.path()) + .arg("--hook") + .output()?; + + let stderr = String::from_utf8(output.stderr)?; + assert!(!output.status.success()); + assert!( + stderr.contains("--hook mode is not supported with jj"), + "expected jj hook rejection, got: {stderr}" + ); + + temp_dir.close()?; + Ok(()) + } + + #[test] + fn test_colocated_prefers_jj() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + + create_colocated_jj_repo(temp_dir.path()); + + assert!(temp_dir.path().join(".jj").is_dir()); + assert!(temp_dir.path().join(".git").is_dir()); + + let output = Command::new(&bin_path) + .arg("-C") + .arg(temp_dir.path()) + .arg("--hook") + .output()?; + + let stderr = String::from_utf8(output.stderr)?; + assert!(!output.status.success()); + assert!( + stderr.contains("--hook mode is not supported with jj"), + "expected jj backend chosen for colocated repo, got: {stderr}" + ); + + temp_dir.close()?; + Ok(()) + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_colocated_config_override_to_git() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + let config_temp_dir = setup_config_home()?; + + create_colocated_jj_repo(temp_dir.path()); + + fs::write(temp_dir.path().join(".koji.toml"), "vcs = \"git\"")?; + + // Stage a file so the git backend doesn't complain about empty staging + fs::write(temp_dir.path().join("test.txt"), "hello")?; + let repo = Repository::open(temp_dir.path())?; + let mut index = repo.index()?; + index.add_all(["."].iter(), IndexAddOption::default(), None)?; + index.write()?; + + // --hook mode should work (not rejected) because git backend is forced. + let output = Command::new(&bin_path) + .env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--hook") + .output()?; + + let stderr = String::from_utf8(output.stderr)?; + assert!( + !stderr.contains("--hook mode is not supported with jj"), + "expected git backend due to config override, but jj was chosen: {stderr}" + ); + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) + } + + // ---- Interactive tests (require PTY, not supported on Windows) ---- + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_jj_describe() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + let config_temp_dir = setup_config_home()?; + + create_jj_repo(temp_dir.path()); + + fs::write(temp_dir.path().join("README.md"), "hello")?; + + let mut cmd = Command::new(&bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("-y") + .arg("--autocomplete=true"); + + let mut process = spawn_command(cmd, Some(10000))?; + + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + process.send_line("core")?; + process.flush()?; + process.expect_summary()?; + process.send_line("add readme")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + let eof_output = process.exp_eof(); + + let exitcode = process.process.wait()?; + let success = matches!(exitcode, wait::WaitStatus::Exited(_, 0)); + + if !success { + panic!("Command exited non-zero, end of output: {eof_output:#?}"); + } + + let description = jj_get_description(temp_dir.path()); + assert_eq!( + description, "feat(core): add readme", + "jj describe did not set the expected commit message" + ); + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_jj_describe_in_non_default_workspace() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + let config_temp_dir = setup_config_home()?; + let second_workspace_path = temp_dir.path().join("secondary-workspace"); + + create_jj_repo(temp_dir.path()); + create_jj_workspace(temp_dir.path(), &second_workspace_path, "secondary"); + + fs::write(second_workspace_path.join("README.md"), "hello")?; + + let mut cmd = Command::new(&bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(&second_workspace_path) + .arg("-y") + .arg("--autocomplete=true"); + + let mut process = spawn_command(cmd, Some(10000))?; + + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + process.send_line("workspace")?; + process.flush()?; + process.expect_summary()?; + process.send_line("describe from non-default workspace")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + let eof_output = process.exp_eof(); + + let exitcode = process.process.wait()?; + let success = matches!(exitcode, wait::WaitStatus::Exited(_, 0)); + + if !success { + panic!("Command exited non-zero, end of output: {eof_output:#?}"); + } + + let description = jj_get_description(&second_workspace_path); + assert_eq!( + description, "feat(workspace): describe from non-default workspace", + "jj describe did not set the expected commit message in non-default workspace" + ); + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_jj_stdout_mode() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + let config_temp_dir = setup_config_home()?; + + create_jj_repo(temp_dir.path()); + fs::write(temp_dir.path().join("test.txt"), "content")?; + + let mut cmd = Command::new(&bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--stdout") + .arg("--autocomplete=true"); + + let mut process = spawn_command(cmd, Some(10000))?; + + process.expect_commit_type()?; + process.send_line("fix")?; + process.flush()?; + process.expect_scope()?; + process.send_line("")?; + process.flush()?; + process.expect_summary()?; + process.send_line("resolve issue")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + + let expected_output = "fix: resolve issue"; + let _ = process + .exp_string(expected_output) + .expect("failed to match output"); + + let eof_output = process.exp_eof(); + + let exitcode = process.process.wait()?; + let success = matches!(exitcode, wait::WaitStatus::Exited(_, 0)); + + if !success { + panic!("Command exited non-zero, end of output: {eof_output:#?}"); + } + + let description = jj_get_description(temp_dir.path()); + assert!( + description.is_empty() || !description.contains("fix: resolve issue"), + "stdout mode should not have written to jj, but description is: {description}" + ); + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_jj_pre_population() -> Result<(), Box> { + let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); + let temp_dir = tempfile::tempdir()?; + let config_temp_dir = setup_config_home()?; + + create_jj_repo(temp_dir.path()); + fs::write(temp_dir.path().join("test.txt"), "content")?; + + jj_describe(temp_dir.path(), "docs(readme): update installation guide"); + + // The existing description should be read and pre-populate the prompt. + let mut cmd = Command::new(&bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--stdout") + .arg("--autocomplete=true"); + + let mut process = spawn_command(cmd, Some(10000))?; + + // The prompt should appear; override the pre-populated values. + // Fields with `with_initial_value` need to be cleared with backspaces + // before typing new values since inquire appends to existing text. + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + // Clear pre-populated scope ("readme" = 6 chars) + for _ in 0..6 { + process.send("\x7f")?; + } + process.send_line("api")?; + process.flush()?; + process.expect_summary()?; + // Clear pre-populated summary ("update installation guide" = 25 chars) + for _ in 0..25 { + process.send("\x7f")?; + } + process.send_line("new endpoint")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + + let expected_output = "feat(api): new endpoint"; + let _ = process + .exp_string(expected_output) + .expect("failed to match output"); + + let eof_output = process.exp_eof(); + + let exitcode = process.process.wait()?; + let success = matches!(exitcode, wait::WaitStatus::Exited(_, 0)); + + if !success { + panic!("Command exited non-zero, end of output: {eof_output:#?}"); + } + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) + } +}