diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..d2b3a279a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* commit log graph visualizing branch topology [[@philocalyst](https://github.com/philocalyst)] ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/Cargo.lock b/Cargo.lock index 332a5e4fef..385e61140a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,15 +13,15 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -58,43 +58,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.103" +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 = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arc-swap" @@ -107,19 +117,20 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asyncgit" version = "0.28.1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossbeam-channel", "dirs", "easy-cast", "env_logger", + "foldhash", "fuzzy-matcher", "git2", "git2-hooks", @@ -133,8 +144,9 @@ dependencies = [ "scopetime", "serde", "serial_test", + "smallvec", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-truncate", "url", ] @@ -150,9 +162,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "backtrace" @@ -222,9 +234,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -249,13 +261,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -271,9 +283,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bwrap" @@ -284,11 +296,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[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.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -304,25 +322,26 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytesize" -version = "2.3.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] name = "cc" -version = "1.2.7" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -336,15 +355,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -353,18 +372,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -374,27 +393,30 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[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 = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -407,14 +429,13 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -434,9 +455,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -461,18 +482,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -489,9 +510,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -499,11 +520,11 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.43", + "rustix 0.38.44", "serde", "signal-hook", "signal-hook-mio", @@ -516,7 +537,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -540,9 +561,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -569,9 +590,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -579,34 +600,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -635,7 +655,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -644,7 +664,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -658,9 +678,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -681,7 +698,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -697,7 +714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.6", + "crypto-common 0.1.7", ] [[package]] @@ -733,13 +750,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -759,15 +776,15 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "easy-cast" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f40539c229fc2e4674bdecdf24bfcc2cb83631ca911c78a035fa9f2381c32b" +checksum = "59e68c9e2b6f969aa527564744c81d1ad24da08a36422a268ec6b61002a7eecd" [[package]] name = "either" -version = "1.13.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encode_unicode" @@ -786,25 +803,19 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -815,18 +826,18 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -859,6 +870,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "faster-hex" version = "0.10.0" @@ -871,9 +888,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -901,9 +918,15 @@ name = "filetreelist" version = "0.6.0" dependencies = [ "pretty_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", ] +[[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" @@ -918,9 +941,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -932,12 +955,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" - [[package]] name = "foldhash" version = "0.2.0" @@ -946,9 +963,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -964,15 +981,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -981,20 +998,19 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] @@ -1019,9 +1035,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -1042,15 +1058,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1086,7 +1100,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1095,7 +1109,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -1115,7 +1129,7 @@ dependencies = [ "pretty_assertions", "shellexpand", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1136,7 +1150,7 @@ dependencies = [ "asyncgit", "backtrace", "base64", - "bitflags 2.10.0", + "bitflags 2.13.1", "bugreport", "bwrap", "bytesize", @@ -1175,7 +1189,7 @@ dependencies = [ "two-face", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "which", ] @@ -1229,14 +1243,14 @@ dependencies = [ "gix-worktree-stream", "nonempty", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-actor" -version = "0.41.1" +version = "0.41.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" dependencies = [ "bstr", "gix-date", @@ -1256,24 +1270,24 @@ dependencies = [ "gix-trace", "kstring", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-bom", ] [[package]] name = "gix-bitmap" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" dependencies = [ "gix-error", ] @@ -1319,7 +1333,7 @@ dependencies = [ "gix-ref", "gix-sec", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-bom", ] @@ -1329,11 +1343,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-path", "libc", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1369,7 +1383,7 @@ dependencies = [ "gix-trace", "gix-traverse", "gix-worktree", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1389,7 +1403,7 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1404,7 +1418,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1432,7 +1446,7 @@ dependencies = [ "once_cell", "parking_lot", "prodash", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "zlib-rs", ] @@ -1455,7 +1469,7 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1469,7 +1483,7 @@ dependencies = [ "gix-features", "gix-path", "gix-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1478,7 +1492,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-features", "gix-path", @@ -1494,7 +1508,7 @@ dependencies = [ "gix-features", "sha1-checked", "sha2 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1523,12 +1537,12 @@ dependencies = [ [[package]] name = "gix-imara-diff" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1537,7 +1551,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "filetime", "fnv", @@ -1556,7 +1570,7 @@ dependencies = [ "memmap2", "rustix 1.1.4", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1567,14 +1581,14 @@ checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-mailmap" -version = "0.33.1" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828" +checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48" dependencies = [ "bstr", "gix-actor", @@ -1598,7 +1612,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1619,7 +1633,7 @@ dependencies = [ "memmap2", "parking_lot", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1638,7 +1652,7 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "uluru", ] @@ -1651,19 +1665,19 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-path" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbbecb0f8dc5cdf6cbde69133f7072064dfc9da4cf0046913afb6857b07300fa" +checksum = "1ed3e8d7a82e886e17a72e03d4ba0c13db6f2219b6cd4e2900b4cae426ec20c9" dependencies = [ "bstr", "gix-trace", "gix-validate", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1672,13 +1686,13 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-attributes", "gix-config-value", "gix-glob", "gix-path", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1697,7 +1711,7 @@ dependencies = [ "gix-utils", "maybe-async", "nonempty", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1728,7 +1742,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1744,7 +1758,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1753,7 +1767,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-commitgraph", "gix-date", @@ -1779,16 +1793,16 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-sec" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "gix-path", "libc", "windows-sys 0.61.2", @@ -1804,7 +1818,7 @@ dependencies = [ "gix-hash", "gix-lock", "nonempty", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1827,7 +1841,7 @@ dependencies = [ "gix-pathspec", "gix-worktree", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1842,14 +1856,14 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-tempfile" -version = "23.0.1" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" dependencies = [ "dashmap", "gix-fs", @@ -1860,9 +1874,9 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" @@ -1877,7 +1891,7 @@ dependencies = [ "gix-quote", "gix-sec", "gix-url", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1886,7 +1900,7 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1894,7 +1908,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1906,26 +1920,26 @@ dependencies = [ "bstr", "gix-path", "percent-encoding", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-utils" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d773a906e39472c2b00aaf1993cd120d40198c1ff6db07c0ee9a44d4431b66c1" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" dependencies = [ "bstr", "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "unicode-normalization", ] [[package]] name = "gix-validate" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" dependencies = [ "bstr", ] @@ -1981,15 +1995,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "foldhash 0.1.4", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1998,7 +2003,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2009,7 +2014,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2045,16 +2050,17 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core 0.52.0", + "windows-core 0.62.2", ] [[package]] @@ -2068,21 +2074,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2091,105 +2099,61 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2198,9 +2162,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2209,9 +2173,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2219,65 +2183,68 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] [[package]] name = "insta" -version = "1.44.3" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", "regex", "similar", + "strip-ansi-escapes", + "tempfile", ] [[package]] name = "instability" -version = "0.3.6" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "894813a444908c0c8c0e221b041771d107c4a21de1d317dc49bcc66e3c9e5b3f" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2286,9 +2253,9 @@ version = "0.1.3" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -2301,17 +2268,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "log", @@ -2321,22 +2289,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -2349,38 +2327,41 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "kasuari" -version = "0.4.8" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481b4381c813cebeca86bd55c781d21f902f34cf927ec08d6df3dfebcfd2002" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "portable-atomic", + "thiserror 2.0.19", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2388,19 +2369,19 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] [[package]] name = "kstring" -version = "2.0.2" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" dependencies = [ "static_assertions", ] @@ -2417,23 +2398,17 @@ 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.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", @@ -2443,13 +2418,18 @@ 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.3" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.10.0", "libc", ] @@ -2469,9 +2449,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.21" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9b68e50e6e0b26f672573834882eb57759f6db9b3be2ea3c35c91188bb4eaa" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -2481,11 +2461,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -2502,9 +2482,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -2523,17 +2503,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2548,20 +2528,20 @@ dependencies = [ [[package]] name = "maybe-async" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -2595,23 +2575,24 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2620,7 +2601,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2649,7 +2630,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -2675,24 +2656,27 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2702,7 +2686,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2740,17 +2724,23 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -2758,9 +2748,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -2774,18 +2764,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.4.1+3.4.0" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2809,6 +2799,30 @@ dependencies = [ "num-traits", ] +[[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.119", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2834,15 +2848,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -2850,9 +2864,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -2860,25 +2874,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", - "sha2 0.10.8", ] [[package]] @@ -2921,7 +2934,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2935,21 +2948,15 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" @@ -2966,19 +2973,28 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.10.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2995,21 +3011,11 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3034,9 +3040,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3055,9 +3061,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_core", ] @@ -3070,13 +3076,14 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", + "ratatui-termina", "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", @@ -3085,30 +3092,30 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", + "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -3118,10 +3125,21 @@ dependencies = [ ] [[package]] -name = "ratatui-termion" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" dependencies = [ "instability", "ratatui-core", @@ -3130,9 +3148,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3147,17 +3165,17 @@ dependencies = [ "ratatui-core", "ratatui-crossterm", "ratatui-widgets", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -3167,14 +3185,14 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3192,29 +3210,29 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3224,9 +3242,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3235,17 +3253,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ron" -version = "0.12.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -3255,9 +3273,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc_version" @@ -3270,11 +3288,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.43" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3287,7 +3305,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3296,15 +3314,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3315,15 +3333,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3337,23 +3346,17 @@ dependencies = [ "log", ] -[[package]] -name = "sdd" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" - [[package]] name = "semver" -version = "1.0.24" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3361,70 +3364,70 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serial_test" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.2.16", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3440,12 +3443,12 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.16", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3468,30 +3471,30 @@ checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ "dirs", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -3499,9 +3502,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -3510,13 +3513,20 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -3535,30 +3545,27 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3566,6 +3573,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3574,43 +3590,43 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "struct-patch" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e986d2cf6e819bd843319120453d837dfdfa31497c3fee4cefa614b2d182d8c" +checksum = "16d4caaaccd69c9b56c5f5b33d4dca462464d3275230e4d2d3739ba6d4bf5bcb" dependencies = [ "struct-patch-derive", ] [[package]] name = "struct-patch-derive" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c6387c1c7b53060605101b63d93edca618c6cf7ce61839f2ec2a527419fdb5" +checksum = "1671c6f0992b1b4cb4f5f8ea4a58f9a5f7f895a7638ef9690633dcec0aa67944" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3626,9 +3642,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3637,13 +3664,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3663,7 +3690,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", ] @@ -3688,12 +3715,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "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 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3734,7 +3774,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.13.1", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -3752,7 +3792,7 @@ dependencies = [ "pest_derive", "phf", "serde", - "sha2 0.10.8", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -3780,11 +3820,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3795,38 +3835,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -3838,15 +3876,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3854,9 +3892,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3864,9 +3902,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3923,24 +3961,24 @@ checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -3950,7 +3988,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3961,33 +3999,22 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4002,12 +4029,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4025,6 +4052,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -4046,33 +4082,24 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen 0.46.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4083,9 +4110,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4093,60 +4120,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" 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 2.10.0", - "hashbrown 0.15.2", - "indexmap", - "semver", -] - [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -4166,7 +4159,7 @@ dependencies = [ "getrandom 0.3.4", "mac_address", "serde", - "sha2 0.10.8", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4223,13 +4216,11 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" dependencies = [ - "env_home", - "rustix 1.1.4", - "winsafe", + "libc", ] [[package]] @@ -4250,11 +4241,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4275,23 +4266,27 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.57.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-targets 0.52.6", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", ] [[package]] @@ -4302,7 +4297,18 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4313,7 +4319,18 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4332,12 +4349,21 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -4496,117 +4522,17 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "wit-bindgen" -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 2.0.117", - "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 2.0.117", - "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 2.10.0", - "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 = "write16" -version = "1.0.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yansi" @@ -4616,11 +4542,10 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -4628,42 +4553,53 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4672,13 +4608,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4686,3 +4622,9 @@ name = "zlib-rs" version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 4d32071b72..a0130871a4 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -16,6 +16,7 @@ bitflags = "2" crossbeam-channel = "0.5" dirs = "6.0" easy-cast = "0.5" +foldhash = "0.2" fuzzy-matcher = "0.3" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } @@ -29,6 +30,7 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +smallvec = "1" tempfile = "3" thiserror = "2.0" unicode-truncate = "2.0" diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs new file mode 100644 index 0000000000..3430b1cd30 --- /dev/null +++ b/asyncgit/src/graph/buffer.rs @@ -0,0 +1,250 @@ +use super::chunk::{lane_awaiting_parent, LaneSlot}; +use super::{CommitAlias, UnwalkedAlias}; +use std::collections::BTreeMap; + +/// A single mutation of the lane state, recorded while processing one +/// commit. +/// +/// Columns stay fixed while a lane is open. Only the right-hand end +/// may grow or shrink; an interior column can only be replaced in place. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeltaOp { + /// Put `slot` into the existing column `index`. + Set { + index: usize, + slot: Option, + }, + /// Append a new rightmost column, holding `slot`. + Push { slot: LaneSlot }, + /// Drop the rightmost column, which is always vacant. + Pop, +} + +impl DeltaOp { + fn apply(self, state: &mut Vec>) { + match self { + Self::Set { index, slot } => state[index] = slot, + Self::Push { slot } => state.push(Some(slot)), + Self::Pop => { + let removed = state.pop(); + debug_assert_eq!(removed, Some(None)); + } + } + } +} + +/// All lane-state mutations caused by processing a single commit. +/// Applying a `Delta` to the lane state of row `n` yields the lane +/// state of row `n + 1`. +#[derive(Clone, Debug)] +pub struct Delta(pub Vec); + +const CHECKPOINT_INTERVAL: usize = 100; + +/// Delta-compressed history of the graph's lane state. +/// +/// Stores the latest lane state and the deltas that produced it. +/// Periodic checkpoints bound the cost of reconstructing a window +/// with [`Self::decompress`]. +#[derive(Default)] +pub struct Buffer { + /// Lane state after the most recently processed commit. + pub current: Vec>, + + /// One [`Delta`] per processed commit, in the order of the walk. + pub deltas: Vec, + + /// Full lane-state snapshots taken every `CHECKPOINT_INTERVAL` + /// commits, keyed by delta index, for reducing decompression cost. + pub checkpoints: BTreeMap>>, + + /// The [`DeltaOp`]s recorded so far for the commit in flight. + pending_delta: Vec, +} + +impl Buffer { + /// Place a commit and route its optional second parent, reusing + /// an existing lane before reserving another column. + pub fn update( + &mut self, + alias: CommitAlias, + first_parent: Option, + second_parent: Option, + ) { + let slot = first_parent + .map_or(LaneSlot::Settled { alias }, |parent| { + LaneSlot::Flowing { alias, parent } + }); + let placement = self.place_chunk(slot, alias); + // Reserve before closing lanes: their closing corners still + // occupy this row, so their columns cannot host a new lane yet. + if let Some(parent) = second_parent { + if lane_awaiting_parent(&self.current, *parent).is_none() + { + self.reserve_lane_right_of(placement, parent); + } + } + self.consume_alias_in_other_chunks(alias, placement); + self.commit_delta(); + } + + fn place_chunk( + &mut self, + new_chunk: LaneSlot, + alias: CommitAlias, + ) -> usize { + // Prefer a lane whose current occupant awaits this chunk's + // commit as its primary parent, then the leftmost column the + // row above leaves blank, and only then a new column. All + // three land the chunk somewhere no open lane is drawn, so + // nothing already on screen has to move. Settled root columns + // remain occupied: stacking an unrelated commit directly below + // a root would look like a parent edge. + let target = lane_awaiting_parent(&self.current, alias) + .or_else(|| self.first_empty_lane()) + .unwrap_or(self.current.len()); + + if target < self.current.len() { + self.record(DeltaOp::Set { + index: target, + slot: Some(new_chunk), + }); + } else { + self.record(DeltaOp::Push { slot: new_chunk }); + } + target + } + + fn first_empty_lane(&self) -> Option { + self.current.iter().position(Option::is_none) + } + + fn consume_alias_in_other_chunks( + &mut self, + alias: CommitAlias, + skip_index: usize, + ) { + for index in 0..self.current.len() { + if index == skip_index { + continue; + } + + if self.current[index] + .is_some_and(|slot| slot.awaits() == Some(alias)) + { + self.record(DeltaOp::Set { index, slot: None }); + } + } + } + + /// Open the lane that a merge's second parent will arrive in, + /// somewhere to the right of the merge itself. + /// + /// The nearest column the row above leaves blank wins, which keeps + /// the bridge short; failing that the lane goes on the right-hand + /// end. This runs before closing incoming lanes, so a column being + /// closed on this row cannot be reused for the merge's corner. + /// + /// Columns to the left are left alone even when they are free. + /// Reusing them would be just as sound, but it would put a merged + /// branch on the far side of the history it was merged into, and + /// `place_chunk` claims those columns for new branch tips anyway. + fn reserve_lane_right_of( + &mut self, + merge_lane: usize, + parent: UnwalkedAlias, + ) { + let slot = LaneSlot::Reserved { parent }; + let reusable = (merge_lane + 1..self.current.len()) + .find(|&index| self.current[index].is_none()); + + match reusable { + Some(index) => self.record(DeltaOp::Set { + index, + slot: Some(slot), + }), + None => self.record(DeltaOp::Push { slot }), + } + } + + fn commit_delta(&mut self) { + while matches!(self.current.last(), Some(None)) { + self.record(DeltaOp::Pop); + } + + self.deltas + .push(Delta(std::mem::take(&mut self.pending_delta))); + + let step = self.deltas.len(); + if step.is_multiple_of(CHECKPOINT_INTERVAL) { + self.checkpoints.insert(step - 1, self.current.clone()); + } + } + + fn record(&mut self, op: DeltaOp) { + op.apply(&mut self.current); + self.pending_delta.push(op); + } + + pub fn decompress( + &self, + start: usize, + end: usize, + ) -> Vec>> { + let end = end.min(self.deltas.len().saturating_sub(1)); + if self.deltas.is_empty() || start > end { + return Vec::new(); + } + let (current_index, mut state) = + self.checkpoints.range(..=start).next_back().map_or_else( + || (None, Vec::new()), + |(&i, s)| (Some(i), s.clone()), + ); + + let mut history = + Vec::with_capacity(end.saturating_sub(start) + 1); + + if let Some(index) = current_index { + if index >= start && index <= end { + history.push(state.clone()); + } + } + + let loop_start = current_index.map_or(0, |i| i + 1); + + for delta_index in loop_start..=end { + if let Some(delta) = self.deltas.get(delta_index) { + for &op in &delta.0 { + op.apply(&mut state); + } + + if delta_index >= start { + history.push(state.clone()); + } + } else { + break; + } + } + + history + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decompression_clips_requests_to_recorded_rows() { + let mut buffer = Buffer::default(); + let empty: Vec>> = Vec::new(); + assert_eq!(buffer.decompress(0, usize::MAX), empty); + buffer.update(0.into(), None, None); + assert_eq!( + buffer.decompress(0, usize::MAX), + vec![buffer.current.clone()] + ); + assert_eq!(buffer.decompress(1, usize::MAX), empty); + assert_eq!(buffer.decompress(1, 0), empty); + } +} diff --git a/asyncgit/src/graph/cell.rs b/asyncgit/src/graph/cell.rs new file mode 100644 index 0000000000..65bb15e3ab --- /dev/null +++ b/asyncgit/src/graph/cell.rs @@ -0,0 +1,202 @@ +use super::{ConnectionType, GraphCell, LaneIndex}; + +use bitflags::bitflags; + +bitflags! { + /// The neighboring cells a lane joins to. Overlapping lines + /// are merged through this representation rather than + /// a naive overwrite for readability. + /// + /// [`Self::LEFT`] and [`Self::RIGHT`] are a horizontal run that + /// *joins* this cell; [`Self::CROSS`] is one that runs past it + /// without joining. Keeping the two apart is what lets a bridge be + /// drawn behind an unrelated lane, while a bridge that really ends + /// on a lane still gets its junction even when a third line + /// crosses the same cell. + #[derive(Clone, Copy, Default)] + pub(super) struct Directions: u8 { + const UP = 0b0_0001; + const DOWN = 0b0_0010; + const LEFT = 0b0_0100; + const RIGHT = 0b0_1000; + const CROSS = 0b1_0000; + } +} + +impl Directions { + fn vertical(self) -> bool { + self.intersects(Self::UP | Self::DOWN) + } +} + +/// Accumulate geometry before choosing a glyph: a vertical glyph hides +/// a crossing, so converting it back to directions would lose that fact +/// before a later overlay could turn the cell into a junction. +#[derive(Clone, Copy, Default)] +pub(super) struct Cell { + directions: Directions, + color: LaneIndex, + marker: Option, + dotted: bool, +} + +impl Cell { + pub const fn line( + directions: Directions, + color: LaneIndex, + dotted: bool, + ) -> Self { + Self { + directions, + color, + marker: None, + dotted, + } + } + + pub const fn commit( + marker: ConnectionType, + color: LaneIndex, + ) -> Self { + Self { + directions: Directions::empty(), + color, + marker: Some(marker), + dotted: false, + } + } + + /// Preserve commit markers and the color of the vertical lane. + pub fn overlay( + &mut self, + directions: Directions, + color: LaneIndex, + ) { + if self.marker.is_some() { + return; + } + if self.directions.is_empty() + || (!self.directions.vertical() && directions.vertical()) + { + self.color = color; + } + self.directions |= directions; + } + + pub const fn finish(self) -> GraphCell { + let connection = match self.marker { + Some(marker) => marker, + None if self.directions.is_empty() => return None, + None => { + directions_to_connection(self.directions, self.dotted) + } + }; + Some((connection, self.color)) + } +} + +const fn vertical_connection(dotted: bool) -> ConnectionType { + if dotted { + ConnectionType::VerticalDotted + } else { + ConnectionType::Vertical + } +} + +/// Synthesize a connection glyph from direction components. +const fn directions_to_connection( + dirs: Directions, + dotted: bool, +) -> ConnectionType { + let up = dirs.contains(Directions::UP); + let down = dirs.contains(Directions::DOWN); + let joins_left = dirs.contains(Directions::LEFT); + let joins_right = dirs.contains(Directions::RIGHT); + let crossed = dirs.contains(Directions::CROSS); + + // A lane running straight through the cell. A bridge that merely + // crosses it is drawn behind it, which keeps the lane readable and + // is the whole reason a crossing is tracked apart from a join. A + // bridge that ends on the lane needs a junction instead, and when + // the cell is both joined and crossed there is nothing left to + // hide the join behind, so it takes the full cross. Drawing that + // case as a plain vertical, which is all a single horizontal bit + // could do, loses the merge edge out of the picture entirely. + if up && down { + return match (joins_left, joins_right, crossed) { + (false, false, _) => vertical_connection(dotted), + (true, false, false) => ConnectionType::TeeLeft, + (false, true, false) => ConnectionType::TeeRight, + _ => ConnectionType::Cross, + }; + } + + // Below here the cell has at most one vertical end, so a crossing + // and a join on the same side are drawn alike. + let left = joins_left || crossed; + let right = joins_right || crossed; + + match (up, down, left, right) { + (true, _, true, true) => ConnectionType::TeeUp, + (_, true, true, true) => ConnectionType::TeeDown, + (true, _, true, false) => ConnectionType::BranchUp, + (true, _, false, true) => ConnectionType::BranchUpRight, + (_, true, true, false) => ConnectionType::MergeBridgeStart, + (_, true, false, true) => ConnectionType::MergeBridgeEnd, + (false, false, _, _) => ConnectionType::MergeBridgeMid, + (true | false, _, false, false) => { + vertical_connection(dotted) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_join_and_crossing_preserve_the_junction_in_either_order() { + for overlays in [ + [Directions::CROSS, Directions::LEFT], + [Directions::LEFT, Directions::CROSS], + ] { + let mut cell = Cell::line( + Directions::UP | Directions::DOWN, + 0.into(), + false, + ); + for directions in overlays { + cell.overlay(directions, 1.into()); + } + assert_eq!( + cell.finish(), + Some((ConnectionType::Cross, 0.into())) + ); + } + } + + #[test] + fn a_crossing_preserves_a_dotted_lane_and_its_color() { + let mut cell = Cell::line( + Directions::UP | Directions::DOWN, + 2.into(), + true, + ); + cell.overlay(Directions::CROSS, 1.into()); + assert_eq!( + cell.finish(), + Some((ConnectionType::VerticalDotted, 2.into())) + ); + } + + #[test] + fn overlays_preserve_commit_markers() { + let mut cell = + Cell::commit(ConnectionType::CommitMerge, 2.into()); + cell.overlay(Directions::CROSS | Directions::UP, 1.into()); + assert_eq!( + cell.finish(), + Some((ConnectionType::CommitMerge, 2.into())) + ); + } +} diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs new file mode 100644 index 0000000000..2a790ae811 --- /dev/null +++ b/asyncgit/src/graph/chunk.rs @@ -0,0 +1,53 @@ +use super::{CommitAlias, UnwalkedAlias}; + +/// A lane's occupant at one point in the walk. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaneSlot { + /// A walked commit flowing down to its first parent. + Flowing { + alias: CommitAlias, + parent: UnwalkedAlias, + }, + + /// A root commit: draws its node, nothing continues below. + Settled { alias: CommitAlias }, + + /// Reserved for a merge's second parent that hasn't been reached + /// by the walk yet. Once that parent is processed, the + /// reservation is replaced by the parent's own slot. + Reserved { parent: UnwalkedAlias }, +} + +impl LaneSlot { + /// The commit this lane currently belongs to, `None` for a + /// reserved placeholder. + pub const fn alias(&self) -> Option { + match self { + Self::Flowing { alias, .. } | Self::Settled { alias } => { + Some(*alias) + } + Self::Reserved { .. } => None, + } + } + + /// The parent whose placement this lane waits on; the lane stays + /// open (drawing a vertical line) until that commit is walked. + pub fn awaits(&self) -> Option { + match self { + Self::Flowing { parent, .. } + | Self::Reserved { parent } => Some(**parent), + Self::Settled { .. } => None, + } + } +} + +/// The first lane already heading for `parent`. Placement, reservation, +/// and bridge routing must all choose the same lane. +pub(super) fn lane_awaiting_parent( + lanes: &[Option], + parent: CommitAlias, +) -> Option { + lanes.iter().position(|slot| { + slot.is_some_and(|slot| slot.awaits() == Some(parent)) + }) +} diff --git a/asyncgit/src/graph/harness/generated.rs b/asyncgit/src/graph/harness/generated.rs new file mode 100644 index 0000000000..de4f7ba6cf --- /dev/null +++ b/asyncgit/src/graph/harness/generated.rs @@ -0,0 +1,178 @@ +use super::Scene; + +/// A deterministic generator, so a generated topology is reproducible +/// from its seed alone without pulling in a property-testing +/// dependency. +struct Rng(u64); + +impl Rng { + const fn new(seed: u64) -> Self { + // Keep the low bit of the state moving. + Self(seed.wrapping_mul(2).wrapping_add(1)) + } + + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 ^ (self.0 >> 33) + } + + /// A value in `0..upper`, or `0` when `upper` is zero. + fn below(&mut self, upper: usize) -> usize { + let upper = u64::try_from(upper).unwrap_or(u64::MAX).max(1); + usize::try_from(self.next() % upper).unwrap_or(0) + } + + /// True one time in `odds`. + fn chance(&mut self, odds: usize) -> bool { + self.below(odds) == 0 + } +} + +/// The kind of history to generate. +#[derive(Clone, Copy, Debug)] +pub enum Shape { + /// A single lane, each commit the parent of the one above it. + Linear, + /// Long-running branches that merge back often. + MergeHeavy, + /// Short topic branches off a trunk, merged or abandoned. + Topics, + /// Several disjoint histories, each ending in its own root. + ManyRoots, + /// Merges whose own first parent is a commit later merges reach + /// too. + /// + /// Both parents are drawn from the same spread, so a mainline can + /// skip over a side branch the way a real one does. Shapes whose + /// first parent is always the very next commit cannot produce + /// that, and miss merges whose second-parent lanes are shared + /// across several rows. + Entangled, + /// Parent lists Git permits and a walk has to survive: duplicated + /// entries, octopus merges, and parents a clock-skewed walk + /// reaches before their children. + Adversarial, +} + +impl Shape { + pub const ALL: [Self; 6] = [ + Self::Linear, + Self::MergeHeavy, + Self::Topics, + Self::ManyRoots, + Self::Entangled, + Self::Adversarial, + ]; +} + +fn push_later( + parents: &mut Vec, + name: usize, + remaining: usize, + offset: usize, +) { + if offset > 0 && offset <= remaining { + parents.push(name + offset); + } +} + +/// Generate a history of `commits` rows, newest first. +/// +/// Commits are named `1..=commits` and listed in that order, so a +/// parent named above its child is one the walk reaches later: the +/// ordering a topological walk produces. [`Shape::Adversarial`] breaks +/// that on purpose for some parents, which is what a repository with +/// skewed commit times hands the walker. +pub fn generate( + seed: u64, + commits: usize, + shape: Shape, +) -> Vec<(usize, Vec)> { + let mut rng = Rng::new(seed); + let mut history = Vec::with_capacity(commits); + + for index in 0..commits { + let name = index + 1; + let left = commits - index - 1; + let mut parents: Vec = Vec::new(); + + match shape { + Shape::Linear => { + push_later(&mut parents, name, left, 1); + } + Shape::MergeHeavy => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 2 + rng.below(10); + push_later(&mut parents, name, left, offset); + } + } + Shape::Topics => { + let offset = 1 + rng.below(3); + push_later(&mut parents, name, left, offset); + if rng.chance(4) { + let offset = 2 + rng.below(20); + push_later(&mut parents, name, left, offset); + } + } + Shape::ManyRoots => { + if !rng.chance(8) { + push_later(&mut parents, name, left, 1); + } + } + Shape::Entangled => { + const SPAN: usize = 10; + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + if rng.chance(2) { + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + } + } + Shape::Adversarial => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + if rng.chance(5) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + // A parent the walk has already passed, and a + // repeated entry. Both are legal in a commit object + // and neither may open a lane. + if rng.chance(4) && index > 0 { + parents.push(1 + rng.below(index)); + } + if rng.chance(3) { + if let Some(first) = parents.first().copied() { + parents.push(first); + } + } + } + } + + history.push((name, parents)); + } + + history +} + +/// Walk a generated history and hold it to every invariant. +pub fn walk_generated( + seed: u64, + commits: usize, + shape: Shape, +) -> Scene { + let history = generate(seed, commits, shape); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + Scene::walk(&borrowed) +} diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs new file mode 100644 index 0000000000..85bb19a8f4 --- /dev/null +++ b/asyncgit/src/graph/harness/mod.rs @@ -0,0 +1,730 @@ +//! Test scaffolding for the commit graph. +//! +//! A graph test used to be a golden drawing: walk a history, render it +//! to ASCII, compare against a picture written by hand. That catches +//! cosmetic drift, but on its own it cannot tell a correct graph from a +//! wrong one, because the cheapest way to make a failing golden test +//! pass is to paste in whatever the renderer now produces. A regression +//! then arrives already blessed, with a confident comment explaining +//! the new picture. +//! +//! So [`Scene::walk`] holds every history to the invariants below +//! before any test gets to look at it, and a golden drawing is left to +//! do what a drawing is actually good at: saying which of the legal +//! pictures we want. +//! +//! The invariants come in two kinds. Some are about the lane state — +//! a lane keeps its column, a delta only addresses columns that exist, +//! a window of rows rebuilds to what the whole walk drew. The sharpest +//! ones are not: [`readback`] reads the finished picture back into the +//! history a person would read off the screen, using nothing but the +//! meaning of the box-drawing characters, and that is compared against +//! the parent lists Git handed us. A renderer cannot satisfy those by +//! being self-consistent. + +mod generated; +mod readback; +mod tests; + +pub use generated::{walk_generated, Shape}; + +use super::buffer::DeltaOp; +use super::chunk::LaneSlot; +use super::{CommitAlias, ConnectionType, GraphRow, GraphWalker}; +use crate::sync::CommitId; +use readback::{side_name, Cursor, Picture}; +use std::collections::{BTreeSet, HashMap, HashSet}; + +/// How many parents a commit can have an edge drawn for. +const DRAWN_PARENTS: usize = 2; + +/// Above this many rows, window checks sample offsets instead of +/// trying every one. +const EXHAUSTIVE_WINDOW_ROWS: usize = 64; + +/// The commit id test histories use for the commit named `n`. +pub fn oid(n: usize) -> CommitId { + CommitId::from_str_unchecked(&format!("{n:040x}")) + .expect("valid oid") +} + +/// One walked history: the rows it draws, and the lane state behind +/// them. +pub struct Scene { + pub walker: GraphWalker, + pub ids: Vec, + pub parents: Vec>, + pub rows: Vec, + pub snapshots: Vec>>, + row_of: HashMap, + branch_tips: HashSet, + stashes: HashSet, + head: Option, +} + +impl Scene { + /// Walk `history`, newest commit first, with commits named by + /// small integers: `(commit, parents)`. + pub fn walk(history: &[(usize, &[usize])]) -> Self { + Self::walk_marked(history, &[], &[], None) + } + + /// [`Self::walk`] with branch tips, stashes and `HEAD` named by the + /// same integers. + pub fn walk_marked( + history: &[(usize, &[usize])], + branch_tips: &[usize], + stashes: &[usize], + head: Option, + ) -> Self { + let named = history.iter().map(|(commit, parents)| { + (oid(*commit), parents.iter().copied().map(oid).collect()) + }); + + Self::walk_ids( + named, + branch_tips.iter().copied().map(oid).collect(), + stashes.iter().copied().map(oid).collect(), + head.map(oid), + ) + } + + /// Walk a history already expressed as commit ids. + pub fn walk_ids( + history: impl IntoIterator)>, + branch_tips: HashSet, + stashes: HashSet, + head: Option, + ) -> Self { + let mut walker = GraphWalker::new(); + let mut ids = Vec::new(); + let mut parents = Vec::new(); + let mut row_of = HashMap::new(); + let mut snapshots = Vec::new(); + + for (commit, commit_parents) in history { + walker.process(commit, &commit_parents); + snapshots.push(walker.buffer.current.clone()); + row_of.entry(commit).or_insert(ids.len()); + ids.push(commit); + parents.push(commit_parents); + } + + let rows = walker.compute_rows( + &ids, + 0, + &branch_tips, + &stashes, + head.as_ref(), + ); + + let scene = Self { + walker, + ids, + parents, + rows, + snapshots, + row_of, + branch_tips, + stashes, + head, + }; + scene.assert_invariants(); + scene + } + + /// The picture as the log view draws it: one glyph per lane column, + /// plus the spacer that carries a bridge's horizontal run. + fn cells(&self) -> Vec> { + self.rows + .iter() + .map(|row| { + row.columns() + .flatten() + .flat_map(|cell| { + cell.map_or(" ", |(connection, _)| { + connection.symbol() + }) + .chars() + }) + .collect() + }) + .collect() + } + + /// The drawn rows, trailing blanks trimmed. + pub fn drawing(&self) -> Vec { + self.cells() + .iter() + .map(|cells| { + cells.iter().collect::().trim_end().to_owned() + }) + .collect() + } + + /// Assert the rows draw exactly `expected`. + pub fn assert_drawing(&self, expected: &[&str]) { + assert_eq!( + self.drawing(), + expected, + "\ndrawn:\n{}", + self.picture() + ); + } + + /// Assert no lane is still waiting on a parent, which is how a + /// history whose every parent was walked has to end. + pub fn assert_fully_settled(&self) { + for slot in self.walker.buffer.current.iter().flatten() { + assert!( + matches!(slot, LaneSlot::Settled { .. }), + "lane still waiting on a parent after a complete \ + walk: {slot:?}\n{}", + self.picture() + ); + } + } + + /// How many columns are still open at the end of the walk. + pub fn open_lane_count(&self) -> usize { + self.walker + .buffer + .current + .iter() + .flatten() + .filter(|slot| slot.awaits().is_some()) + .count() + } + + /// The drawing with row numbers, for failure messages. + pub fn picture(&self) -> String { + self.drawing() + .iter() + .enumerate() + .map(|(index, row)| format!("{index:>4} {row}")) + .collect::>() + .join("\n") + } + + fn alias(&self, index: usize) -> CommitAlias { + self.walker + .oids + .get(&self.ids[index]) + .expect("every walked commit has an alias") + } + + /// Where the row's commit marker sits in the drawing. + fn marker(&self, index: usize) -> Cursor { + (index, usize::from(self.rows[index].commit_lane) * 2) + } + + fn slot(&self, index: usize, column: usize) -> Option { + self.snapshots.get(index)?.get(column).copied().flatten() + } + + /// The parents this row was entitled to draw an edge to: the first + /// two distinct parents the walk had not already passed. + /// + /// This policy is stated independently of the walker's parent + /// selection. Each row supports at most one merge bridge; repeated, + /// self-naming, and already-walked parents must not open lanes. + fn drawn_parents(&self, index: usize) -> Vec { + let id = &self.ids[index]; + let mut drawn: Vec = Vec::new(); + + for parent in &self.parents[index] { + if drawn.len() == DRAWN_PARENTS { + break; + } + let walked_already = self + .row_of + .get(parent) + .is_some_and(|row| *row <= index); + if parent == id || walked_already { + continue; + } + if !drawn.contains(parent) { + drawn.push(*parent); + } + } + + drawn + } + + /// Rows of the parents this row draws an edge to, for the parents + /// that are themselves in the walked window. + fn drawn_parent_rows(&self, index: usize) -> Vec { + self.drawn_parents(index) + .iter() + .filter_map(|parent| self.row_of.get(parent).copied()) + .collect() + } +} + +/// The invariants every scene is held to. +impl Scene { + fn assert_invariants(&self) { + self.assert_snapshots_track_the_walk(); + self.assert_frontier_matches_the_walk(); + self.assert_delta_stream_is_well_formed(); + self.assert_columns_hold_still(); + self.assert_rows_are_well_formed(); + self.assert_windows_decompress_exactly(); + + self.assert_picture_preserves_history(&Picture::new( + &self.cells(), + )); + } + + fn assert_picture_preserves_history(&self, picture: &Picture) { + let joined = (0..self.rows.len()) + .map(|index| { + picture.commits_joined_to(self.marker(index)) + }) + .collect::>(); + + self.assert_no_dangling_lines(picture); + self.assert_every_parent_edge_is_drawn(&joined); + self.assert_unrelated_histories_stay_apart(&joined); + self.assert_stacked_commits_are_parent_and_child(); + } + + /// One snapshot per walked commit, ending in the live lane state. + fn assert_snapshots_track_the_walk(&self) { + assert_eq!( + self.snapshots.len(), + self.ids.len(), + "the walk lost snapshots" + ); + assert_eq!( + self.walker.processed_commit_count(), + self.ids.len(), + "the buffer holds a delta count the walk did not produce" + ); + if let Some(last) = self.ids.len().checked_sub(1) { + assert_eq!( + self.walker.buffer.decompress(0, last), self.snapshots, + "delta replay differs from the states recorded during the walk" + ); + } + + if let Some(last) = self.snapshots.last() { + assert_eq!( + last, &self.walker.buffer.current, + "the last recorded snapshot is not the live state" + ); + } + } + + /// The raw history determines which parents are still ahead of + /// each row. Every one needs a lane, and no lane may await anything + /// else, including a parent the walk has already passed. + fn assert_frontier_matches_the_walk(&self) { + let mut expected = HashSet::new(); + for (index, snapshot) in self.snapshots.iter().enumerate() { + expected.remove(&self.alias(index)); + for parent in self.drawn_parents(index) { + expected.insert( + self.walker + .oids + .get(&parent) + .expect("a drawable parent has an alias"), + ); + } + let actual = snapshot + .iter() + .flatten() + .filter_map(LaneSlot::awaits) + .collect::>(); + assert_eq!( + actual, + expected, + "row {index} awaits the wrong parents\n{}", + self.picture() + ); + } + } + + /// Every recorded operation addresses a column that exists, and a + /// column is only ever dropped once it is vacant. + /// + /// Replaying the stream is what makes the delta buffer's promise + /// testable. The three operations cannot express a sideways move + /// at all; this checks that nothing in the stream leans on a + /// column which was never opened, which is the other way a replay + /// could drift from the walk. + fn assert_delta_stream_is_well_formed(&self) { + let mut columns: Vec = Vec::new(); + + for (index, delta) in + self.walker.buffer.deltas.iter().enumerate() + { + for op in &delta.0 { + match *op { + DeltaOp::Set { + index: column, + slot, + } => { + assert!( + column < columns.len(), + "delta {index} sets column {column} of \ + {}", + columns.len() + ); + columns[column] = slot.is_some(); + } + DeltaOp::Push { .. } => columns.push(true), + DeltaOp::Pop => { + assert_eq!( + columns.pop(), + Some(false), + "delta {index} dropped a column that \ + still held a lane" + ); + } + } + } + } + } + + /// A lane keeps its column for as long as it stays open. + /// + /// Rows are drawn one at a time and joined to their neighbours by + /// column, so a lane that changed column between two rows would be + /// two line segments that do not meet. The only lanes allowed to + /// leave a column are the ones the row's own commit closes, by + /// being the parent they were waiting for. + fn assert_columns_hold_still(&self) { + for index in 1..self.snapshots.len() { + let alias = self.alias(index); + + for column in 0..self.snapshots[index - 1].len() { + let Some(awaited) = self + .slot(index - 1, column) + .and_then(|slot| slot.awaits()) + else { + continue; + }; + let now = self.slot(index, column); + + if awaited == alias { + assert!( + now.is_none() + || now.and_then(|slot| slot.alias()) + == Some(alias), + "row {index} took over column {column} with \ + a lane that is not its own commit\n{}", + self.picture() + ); + continue; + } + + assert_eq!( + now.and_then(|slot| slot.awaits()), + Some(awaited), + "a lane waiting for {awaited:?} left column \ + {column} between rows {} and {index}\n{}", + index - 1, + self.picture() + ); + } + } + } + + /// Each row's own bookkeeping agrees with what it drew. + fn assert_rows_are_well_formed(&self) { + for (index, row) in self.rows.iter().enumerate() { + let width = row.lanes.len(); + + let markers = row + .lanes + .iter() + .enumerate() + .filter(|(_, cell)| { + cell.is_some_and(|(conn, _)| conn.is_commit()) + }) + .map(|(column, _)| column) + .collect::>(); + assert_eq!( + markers, + vec![usize::from(row.commit_lane)], + "row {index} draws its commit somewhere other than \ + its own lane\n{}", + self.picture() + ); + + assert_eq!( + row.is_merge, + self.parents[index].len() > 1, + "row {index} disagrees with Git about being a merge" + ); + assert_eq!( + row.is_branch_tip, + self.branch_tips.contains(&self.ids[index]), + "row {index} disagrees about being a branch tip" + ); + assert_eq!( + row.is_stash, + self.stashes.contains(&self.ids[index]), + "row {index} disagrees about being a stash" + ); + let expected_marker = + if self.stashes.contains(&self.ids[index]) { + ConnectionType::CommitStash + } else if self.parents[index].len() > 1 { + ConnectionType::CommitMerge + } else if self.branch_tips.contains(&self.ids[index]) + { + ConnectionType::CommitBranch + } else { + ConnectionType::CommitNormal + }; + assert_eq!( + row.lanes[usize::from(row.commit_lane)] + .map(|(connection, _)| connection), + Some(expected_marker), + "row {index} draws the wrong commit marker" + ); + + for (from, to) in row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()) + { + let (from, to) = (usize::from(from), usize::from(to)); + assert!(from < to, "row {index} has an empty bridge"); + assert!( + to < width, + "row {index} bridges past its own width" + ); + assert!( + from == usize::from(row.commit_lane) + || to == usize::from(row.commit_lane), + "row {index} has a bridge with neither end at \ + its commit" + ); + } + } + } + + /// No line in the picture just stops. + /// + /// Every line end has to point at a cell that takes it up. This is + /// the local form of the column invariant, read off the drawing + /// rather than the lane state: a lane that moved sideways leaves a + /// vertical hanging in the column it left and another one with + /// nothing above it in the column it moved to. + fn assert_no_dangling_lines(&self, picture: &Picture) { + let dangling = picture + .dangling_ends() + .iter() + .map(|&((row, column), side)| { + format!( + "row {row} column {column} runs {} into nothing", + side_name(side) + ) + }) + .collect::>(); + + assert!( + dangling.is_empty(), + "{}\n{}", + dangling.join("\n"), + self.picture() + ); + } + + /// Every edge the walk was entitled to draw can be followed in the + /// picture. + /// + /// This is the invariant a golden drawing cannot give us. It reads + /// the finished characters back into a history and insists that + /// each commit really does reach each of its parents through the + /// lines on screen, which is the whole claim the graph column + /// makes. + fn assert_every_parent_edge_is_drawn( + &self, + joined: &[BTreeSet], + ) { + for (index, reached) in joined.iter().enumerate() { + for parent in self.drawn_parent_rows(index) { + assert!( + reached.contains(&self.marker(parent)), + "row {index} cannot be followed to its parent \ + on row {parent}\n{}", + self.picture() + ); + } + } + } + + /// The picture never joins two commits from histories that share + /// no ancestry. + /// + /// Lanes are shared on purpose — a merge bridges into a lane that + /// already leads to the same parent instead of opening a second + /// one — so a drawing legitimately joins commits that are not + /// directly related. What it must never do is run a line between + /// two unrelated histories, which is what reusing a column too + /// eagerly looks like on screen. + fn assert_unrelated_histories_stay_apart( + &self, + joined: &[BTreeSet], + ) { + let components = self.ancestry_components(); + + for (index, reached) in joined.iter().enumerate() { + for &(row, _) in reached { + assert_eq!( + components[index], + components[row], + "the picture joins row {index} to row {row}, \ + which share no ancestry\n{}", + self.picture() + ); + } + } + } + + /// Two commits drawn one directly above the other in a column read + /// as joined, because a lane draws no line between consecutive + /// commits on it. So they have to be parent and child. + fn assert_stacked_commits_are_parent_and_child(&self) { + for index in 1..self.rows.len() { + if self.rows[index - 1].commit_lane + != self.rows[index].commit_lane + { + continue; + } + + assert!( + self.drawn_parent_rows(index - 1).contains(&index), + "rows {} and {index} are drawn stacked in column \ + {:?} but are not parent and child\n{}", + index - 1, + self.rows[index].commit_lane, + self.picture() + ); + } + } + + /// Reading a window of rows draws the same thing as reading the + /// whole walk and slicing it. + /// + /// The log view only ever asks for the rows it can show, so every + /// window is rebuilt from the delta buffer and the nearest + /// checkpoint. Short histories check every offset; longer ones + /// sample, which still puts a checkpoint boundary inside the + /// window for any history long enough to have one. + fn assert_windows_decompress_exactly(&self) { + let total = self.ids.len(); + let stride = total.div_ceil(EXHAUSTIVE_WINDOW_ROWS).max(1); + + for length in [1, 2, 3, 7, total] { + if length == 0 || length > total { + continue; + } + + let mut starts = (0..=total - length) + .step_by(stride) + .collect::>(); + starts.insert(total - length); + for &checkpoint in self.walker.buffer.checkpoints.keys() { + for start in + checkpoint.saturating_sub(1)..=checkpoint + 2 + { + if start + length <= total { + starts.insert(start); + } + } + } + + for start in starts { + let end = start + length; + assert_eq!( + self.walker.buffer.decompress(start, end - 1), + self.snapshots[start..end], + "lane states {start}..{end} differ from the recorded walk" + ); + let window = self.walker.compute_rows( + &self.ids[start..end], + start, + &self.branch_tips, + &self.stashes, + self.head.as_ref(), + ); + + assert_eq!( + window, + self.rows[start..end], + "rows {start}..{end} drew differently on their \ + own\n{}", + self.picture() + ); + } + } + } + + /// A component id per row, joining each commit to the parents it + /// draws an edge to. + /// + /// Two children of the same unwalked parent land in one component + /// as well: they share ancestry the window does not show, and the + /// picture is allowed to route both through one lane. + fn ancestry_components(&self) -> Vec { + let mut parent_keys: HashMap = + HashMap::new(); + let mut union = Union::new(self.rows.len()); + + for index in 0..self.rows.len() { + for parent in self.drawn_parents(index) { + if let Some(row) = self.row_of.get(&parent) { + union.join(index, *row); + continue; + } + + let next = union.len(); + let key = *parent_keys.entry(parent).or_insert(next); + if key == next { + union.grow(); + } + union.join(index, key); + } + } + + (0..self.rows.len()).map(|row| union.root(row)).collect() + } +} + +/// Disjoint-set forest over row indices. +struct Union(Vec); + +impl Union { + fn new(size: usize) -> Self { + Self((0..size).collect()) + } + + fn len(&self) -> usize { + self.0.len() + } + + fn grow(&mut self) { + self.0.push(self.0.len()); + } + + fn root(&mut self, mut node: usize) -> usize { + while self.0[node] != node { + let parent = self.0[node]; + self.0[node] = self.0[parent]; + node = self.0[node]; + } + node + } + + fn join(&mut self, left: usize, right: usize) { + let (left, right) = (self.root(left), self.root(right)); + if left != right { + self.0[left] = right; + } + } +} diff --git a/asyncgit/src/graph/harness/readback.rs b/asyncgit/src/graph/harness/readback.rs new file mode 100644 index 0000000000..cf5ff3597e --- /dev/null +++ b/asyncgit/src/graph/harness/readback.rs @@ -0,0 +1,351 @@ +//! Reads a drawn graph back into the history it depicts. +//! +//! Everything else in these tests would share the renderer's own idea +//! of what a glyph means, so a mistake in that table would be agreed +//! with rather than caught. This module starts from the characters +//! instead. `┫` joins up, down and left because that is what BOX +//! DRAWINGS HEAVY VERTICAL AND LEFT *is*, not because the walker says +//! so, and the table below is the only thing this module is told. +//! +//! What comes out is the history a person reads off the screen. Two +//! rules make that reading precise: +//! +//! - A commit marker is a node. Lines meet there and paths end there, +//! so two commits are joined only by a run of line cells with no +//! other commit on it. Two markers drawn one directly above the +//! other in a column are joined as well, since that is how a lane +//! with no line between consecutive commits is drawn. +//! - A plain vertical is transparent to a horizontal run: `o━┃━┛` +//! reads as a bridge passing *behind* an unrelated lane, not as a +//! junction with it. A run that really joins a lane is drawn as one +//! of the tees, so entering a vertical sideways can only leave it +//! sideways. + +use std::collections::{BTreeSet, HashSet, VecDeque}; + +/// The side of a cell a line leaves by. +pub const UP: u8 = 1 << 0; +pub const DOWN: u8 = 1 << 1; +pub const LEFT: u8 = 1 << 2; +pub const RIGHT: u8 = 1 << 3; + +/// Row and column of a cell in the drawing. +pub type Cursor = (usize, usize); + +pub const fn side_name(side: u8) -> &'static str { + match side { + UP => "up", + DOWN => "down", + LEFT => "left", + _ => "right", + } +} + +const fn opposite(side: u8) -> u8 { + match side { + UP => DOWN, + DOWN => UP, + LEFT => RIGHT, + _ => LEFT, + } +} + +/// What a character is, going by the character alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cell { + /// A commit. Lines meet here, and a path through the drawing ends + /// here. + Node, + /// A line joining exactly these sides. + Joint(u8), + /// A plain vertical, which a horizontal run passes behind without + /// joining. + Crossing, + /// Nothing drawn. + Blank, +} + +fn classify(cell: char) -> Cell { + match cell { + 'o' | 'M' | '*' | '@' | '+' => Cell::Node, + '┃' | '╏' => Cell::Crossing, + '━' => Cell::Joint(LEFT | RIGHT), + '┓' => Cell::Joint(DOWN | LEFT), + '┏' => Cell::Joint(DOWN | RIGHT), + '┛' => Cell::Joint(UP | LEFT), + '┗' => Cell::Joint(UP | RIGHT), + '┫' => Cell::Joint(UP | DOWN | LEFT), + '┣' => Cell::Joint(UP | DOWN | RIGHT), + '┻' => Cell::Joint(UP | LEFT | RIGHT), + '┳' => Cell::Joint(DOWN | LEFT | RIGHT), + '╋' => Cell::Joint(UP | DOWN | LEFT | RIGHT), + // A blank, and anything the log view does not draw as part of + // the graph, carries no line. + _ => Cell::Blank, + } +} + +/// A rectangular picture: one `char` per cell, every row the same +/// width. +pub struct Picture(Vec>); + +impl Picture { + /// Pad `rows` into a rectangle. + /// + /// Padding matters: a line arriving in a column that the row above + /// does not even reach is exactly the failure this module is here + /// to find, so the short row has to be filled with blanks rather + /// than treated as absent. + pub fn new(rows: &[Vec]) -> Self { + let width = + rows.iter().map(Vec::len).max().unwrap_or_default(); + Self( + rows.iter() + .map(|row| { + let mut row = row.clone(); + row.resize(width, ' '); + row + }) + .collect(), + ) + } + + fn at(&self, (row, column): Cursor) -> char { + self.0 + .get(row) + .and_then(|row| row.get(column)) + .copied() + .unwrap_or(' ') + } + + fn neighbour( + &self, + (row, column): Cursor, + side: u8, + ) -> Option { + let next = match side { + UP => (row.checked_sub(1)?, column), + DOWN => (row + 1, column), + LEFT => (row, column.checked_sub(1)?), + _ => (row, column + 1), + }; + + (next.0 < self.0.len() + && next.1 < self.0.first().map_or(0, Vec::len)) + .then_some(next) + } + + /// Whether a line arriving from `from` is taken up by `cell`. + fn accepts(cell: char, from: u8) -> bool { + match classify(cell) { + // A commit takes a line from any side, and a vertical + // takes one from any side too: from above or below it + // continues the lane, from either side it passes behind. + Cell::Node | Cell::Crossing => true, + Cell::Joint(sides) => sides & from != 0, + Cell::Blank => false, + } + } + + /// The cell a line leaving `cursor` by `side` arrives in, if there + /// is one there to take it. + fn step(&self, cursor: Cursor, side: u8) -> Option { + let next = self.neighbour(cursor, side)?; + Self::accepts(self.at(next), opposite(side)).then_some(next) + } + + /// Which commits the drawing joins to the commit at `start`. + pub fn commits_joined_to( + &self, + start: Cursor, + ) -> BTreeSet { + let mut found = BTreeSet::new(); + let mut seen: HashSet<(Cursor, u8)> = HashSet::new(); + let mut queue: VecDeque<(Cursor, u8)> = VecDeque::new(); + + // A commit sends a line out of all four sides; which of them + // go anywhere is decided by the cell on the other side. + for side in [UP, DOWN, LEFT, RIGHT] { + if let Some(next) = self.step(start, side) { + queue.push_back((next, opposite(side))); + } + } + + while let Some((cursor, entered)) = queue.pop_front() { + if !seen.insert((cursor, entered)) { + continue; + } + + let onward = match classify(self.at(cursor)) { + Cell::Node => { + if cursor != start { + found.insert(cursor); + } + continue; + } + Cell::Blank => continue, + // Sideways in, sideways out; a run that joined this + // lane would have been drawn as a tee. + Cell::Crossing => opposite(entered), + Cell::Joint(sides) => sides & !entered, + }; + + for side in [UP, DOWN, LEFT, RIGHT] { + if onward & side == 0 { + continue; + } + if let Some(next) = self.step(cursor, side) { + queue.push_back((next, opposite(side))); + } + } + } + + found + } + + /// Every line end that points at a cell with nothing to take it. + /// + /// Lines may continue off the top and bottom of a history window. + /// A horizontal line must end inside the picture. + pub fn dangling_ends(&self) -> Vec<(Cursor, u8)> { + let mut dangling = Vec::new(); + + for (row, cells) in self.0.iter().enumerate() { + for (column, cell) in cells.iter().enumerate() { + let sides = match classify(*cell) { + Cell::Joint(sides) => sides, + // Only the lane is a line end; the sideways + // transparency is not one. + Cell::Crossing => UP | DOWN, + Cell::Node | Cell::Blank => continue, + }; + + for side in [UP, DOWN, LEFT, RIGHT] { + if sides & side == 0 { + continue; + } + let cursor = (row, column); + if matches!(side, UP | DOWN) + && self.neighbour(cursor, side).is_none() + { + continue; + } + if self.step(cursor, side).is_none() { + dangling.push((cursor, side)); + } + } + } + } + + dangling + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::ConnectionType; + + fn picture(rows: &[&str]) -> Picture { + Picture::new( + &rows + .iter() + .map(|row| row.chars().collect()) + .collect::>(), + ) + } + + #[test] + fn renderer_symbols_are_single_recognized_cells() { + use ConnectionType::{ + BranchUp, BranchUpRight, CommitBranch, CommitMerge, + CommitNormal, CommitStash, CommitUncommitted, Cross, + MergeBridgeEnd, MergeBridgeMid, MergeBridgeStart, + TeeDown, TeeLeft, TeeRight, TeeUp, Vertical, + VerticalDotted, + }; + for connection in [ + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + MergeBridgeStart, + MergeBridgeMid, + MergeBridgeEnd, + BranchUp, + BranchUpRight, + TeeLeft, + TeeRight, + TeeUp, + TeeDown, + Cross, + ] { + let glyphs = + connection.symbol().chars().collect::>(); + assert_eq!( + glyphs.len(), + 1, + "{connection:?} occupies more than one cell" + ); + let cell = classify(glyphs[0]); + assert_ne!( + cell, + Cell::Blank, + "unrecognized glyph for {connection:?}" + ); + assert_eq!(cell == Cell::Node, connection.is_commit()); + } + } + + #[test] + fn crossing_preserves_two_separate_paths() { + let graph = picture(&[" o", "o━┃━o", " o"]); + assert_eq!( + graph.commits_joined_to((0, 2)), + BTreeSet::from([(2, 2)]) + ); + assert_eq!( + graph.commits_joined_to((1, 0)), + BTreeSet::from([(1, 4)]) + ); + assert_eq!(graph.dangling_ends(), Vec::<(Cursor, u8)>::new()); + } + + #[test] + fn junction_joins_all_four_paths() { + let graph = picture(&[" o", "o━╋━o", " o"]); + assert_eq!( + graph.commits_joined_to((0, 2)), + BTreeSet::from([(1, 0), (1, 4), (2, 2)]) + ); + assert_eq!(graph.dangling_ends(), Vec::<(Cursor, u8)>::new()); + } + + #[test] + fn commits_stop_paths() { + let graph = picture(&["o", "o", "o"]); + assert_eq!( + graph.commits_joined_to((0, 0)), + BTreeSet::from([(1, 0)]) + ); + } + + #[test] + fn only_vertical_paths_can_leave_the_window() { + assert_eq!( + picture(&["┃"]).dangling_ends(), + Vec::<(Cursor, u8)>::new() + ); + assert_eq!( + picture(&["━"]).dangling_ends(), + vec![((0, 0), LEFT), ((0, 0), RIGHT)] + ); + assert_eq!( + picture(&["o", " ┃"]).dangling_ends(), + vec![((1, 2), UP)] + ); + } +} diff --git a/asyncgit/src/graph/harness/tests.rs b/asyncgit/src/graph/harness/tests.rs new file mode 100644 index 0000000000..843c27f98c --- /dev/null +++ b/asyncgit/src/graph/harness/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +#[should_panic(expected = "cannot be followed to its parent")] +fn readback_rejects_a_missing_merge_junction() { + let scene = + Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]); + let mut cells = scene.cells(); + cells[0][2] = '┃'; + scene.assert_picture_preserves_history(&Picture::new(&cells)); +} + +#[test] +#[should_panic(expected = "which share no ancestry")] +fn readback_rejects_a_join_between_independent_histories() { + let scene = + Scene::walk(&[(1, &[3]), (2, &[4]), (3, &[]), (4, &[])]); + let mut cells = scene.cells(); + cells[1][0] = '┣'; + cells[1][1] = '━'; + scene.assert_picture_preserves_history(&Picture::new(&cells)); +} + +#[test] +#[should_panic(expected = "row 0 awaits the wrong parents")] +fn frontier_rejects_a_missing_parent_outside_the_window() { + let mut scene = Scene::walk(&[(1, &[2])]); + scene.snapshots[0][0] = Some(LaneSlot::Settled { + alias: scene.alias(0), + }); + scene.assert_frontier_matches_the_walk(); +} + +#[test] +#[should_panic(expected = "row 1 awaits the wrong parents")] +fn frontier_rejects_a_lane_waiting_for_a_walked_parent() { + let mut scene = Scene::walk(&[(1, &[2]), (2, &[])]); + scene.snapshots[1] = scene.snapshots[0].clone(); + scene.assert_frontier_matches_the_walk(); +} + +#[test] +#[should_panic( + expected = "lane states 99..100 differ from the recorded walk" +)] +fn windows_reject_a_corrupted_checkpoint() { + let mut scene = walk_generated(7, 120, Shape::Entangled); + scene + .walker + .buffer + .checkpoints + .get_mut(&99) + .expect("the first checkpoint") + .clear(); + scene.assert_windows_decompress_exactly(); +} + +#[test] +#[should_panic(expected = "row 0 draws the wrong commit marker")] +fn rows_reject_a_merge_drawn_as_an_ordinary_commit() { + let mut scene = Scene::walk(&[(1, &[2, 3])]); + scene.rows[0].lanes[0] = + Some((ConnectionType::CommitNormal, 0.into())); + scene.assert_rows_are_well_formed(); +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs new file mode 100644 index 0000000000..21ce349c17 --- /dev/null +++ b/asyncgit/src/graph/mod.rs @@ -0,0 +1,215 @@ +pub mod buffer; +mod cell; +pub mod chunk; +pub mod oids; +pub mod walker; + +#[cfg(test)] +mod harness; + +pub use walker::GraphWalker; + +/// The maximum number of colors to use for graph lanes +pub const MAX_LANE_COLORS: usize = 16; + +/// A lane's column within a row, or its slot in the color palette. +/// Column indices are lossless even when the graph is wider than a terminal. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LaneIndex(usize); + +/// Numeric alias assigned to each commit in the graph. +/// +/// The alias is a dense integer index created by [`GraphOids`](super::oids::GraphOids) +/// that avoids storing full [`CommitId`](crate::sync::CommitId)s inside the lane state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct CommitAlias(usize); + +/// An alias for a commit that had not yet been walked when the value +/// was minted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnwalkedAlias(CommitAlias); + +impl std::ops::Deref for UnwalkedAlias { + type Target = CommitAlias; + fn deref(&self) -> &CommitAlias { + &self.0 + } +} + +impl std::ops::Deref for CommitAlias { + type Target = usize; + fn deref(&self) -> &usize { + &self.0 + } +} + +impl From for CommitAlias { + fn from(v: usize) -> Self { + Self(v) + } +} + +impl From for LaneIndex { + fn from(lane: usize) -> Self { + Self(lane) + } +} + +impl From for usize { + fn from(lane: LaneIndex) -> Self { + lane.0 + } +} + +/// The type of connection between nodes in the graph. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionType { + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + /// a bridge turning down into a lane that starts here + MergeBridgeStart, + /// a bridge passing over an empty lane slot + MergeBridgeMid, + /// a bridge turning down, lane starting to its right + MergeBridgeEnd, + /// a lane from above turning left into a commit + BranchUp, + /// a lane from above turning right into a commit + BranchUpRight, + /// a continuing lane absorbing a bridge from its left + TeeLeft, + /// a continuing lane absorbing a bridge from its right + TeeRight, + /// a lane ending from above while a bridge passes through + TeeUp, + /// a lane starting downward while a bridge passes through + TeeDown, + /// a lane a bridge joins at a cell another bridge runs through + Cross, +} + +impl ConnectionType { + /// The terminal glyph used by both the log view and graph readback tests. + pub const fn symbol(self) -> &'static str { + match self { + Self::Vertical => "┃", + Self::VerticalDotted => "╏", + Self::CommitNormal => "o", + Self::CommitBranch | Self::CommitStash => "*", + Self::CommitMerge => "M", + Self::CommitUncommitted => "+", + Self::MergeBridgeStart => "┓", + Self::MergeBridgeMid => "━", + Self::MergeBridgeEnd => "┏", + Self::BranchUp => "┛", + Self::BranchUpRight => "┗", + Self::TeeLeft => "┫", + Self::TeeRight => "┣", + Self::TeeUp => "┻", + Self::TeeDown => "┳", + Self::Cross => "╋", + } + } + + /// Whether this is a commit marker rather than a line. + /// + /// A marker carries no line of its own and is never drawn over: + /// whether a line reaches it from above, or leaves it below, is a + /// fact about the commit's lane and not about the glyph. + pub const fn is_commit(self) -> bool { + matches!( + self, + Self::CommitNormal + | Self::CommitBranch + | Self::CommitMerge + | Self::CommitStash + | Self::CommitUncommitted + ) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GraphRow { + /// Which lane index this commit sits on + pub commit_lane: LaneIndex, + + /// Whether Git reports more than one parent for this commit + pub is_merge: bool, + + /// Whether this commit is a branch tip + pub is_branch_tip: bool, + + /// Whether this commit has stash marker + pub is_stash: bool, + + /// Connections emitted per lane: + /// None = empty space + /// Some((ConnectionType, `color_index`)) = draw this connector in this color + pub lanes: Vec, + + /// Horizontal merge bridge: if this commit merges rightward, + /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across + pub merge_bridge: Option<(LaneIndex, LaneIndex)>, + + /// Horizontal branch bridges: if this commit spawns branches, + /// spans to draw ─ ╭ ╮ across + pub branches: Vec<(LaneIndex, LaneIndex)>, +} + +/// A glyph and its lane color, or an empty terminal cell. +pub type GraphCell = Option<(ConnectionType, LaneIndex)>; + +impl GraphRow { + /// Number of lane columns, including gaps between open lanes. + pub const fn lane_count(&self) -> usize { + self.lanes.len() + } + + /// Two terminal cells per lane: its connector and the horizontal + /// bridge segment (or blank) immediately to its right. + pub fn columns( + &self, + ) -> impl Iterator + '_ { + self.lanes.iter().enumerate().map(|(lane, &cell)| { + let spacer = self + .merge_bridge + .into_iter() + .chain(self.branches.iter().copied()) + .rfind(|&(from, to)| { + (usize::from(from)..usize::from(to)) + .contains(&lane) + }) + .map(|(from, to)| { + let target = if from == self.commit_lane { + to + } else { + from + }; + ( + ConnectionType::MergeBridgeMid, + LaneIndex::from( + usize::from(target) % MAX_LANE_COLORS, + ), + ) + }); + [cell, spacer] + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lane_indices_preserve_columns_beyond_u16() { + for column in [usize::from(u16::MAX) + 1, usize::MAX] { + assert_eq!(usize::from(LaneIndex::from(column)), column); + } + } +} diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs new file mode 100644 index 0000000000..c981df4766 --- /dev/null +++ b/asyncgit/src/graph/oids.rs @@ -0,0 +1,36 @@ +use super::CommitAlias; +use crate::sync::CommitId; +use std::collections::HashMap; + +/// mapping of `CommitId` to a numeric alias. +/// +/// `foldhash` is a non-cryptographic hasher — plenty for ids that +/// never leave this process — and notably cheaper than the default +/// `SipHash` for the millions of lookups a full history generates. +#[derive(Default)] +pub struct GraphOids( + HashMap, +); + +impl GraphOids { + /// Create an empty alias map. + pub fn new() -> Self { + Self::default() + } + + /// Get the alias for `id`, assigning a new one if it doesn't exist yet. + pub fn get_or_insert(&mut self, id: &CommitId) -> CommitAlias { + if let Some(&alias) = self.0.get(id) { + return alias; + } + + let alias = CommitAlias::from(self.0.len()); + self.0.insert(*id, alias); + alias + } + + /// Look up the alias for `id`, returning `None` if not found. + pub fn get(&self, id: &CommitId) -> Option { + self.0.get(id).copied() + } +} diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs new file mode 100644 index 0000000000..4620ff33c6 --- /dev/null +++ b/asyncgit/src/graph/walker.rs @@ -0,0 +1,568 @@ +use super::buffer::Buffer; +use super::cell::{Cell, Directions}; +use super::chunk::{lane_awaiting_parent, LaneSlot}; +use super::oids::GraphOids; +use super::{ + CommitAlias, ConnectionType, GraphRow, LaneIndex, UnwalkedAlias, + MAX_LANE_COLORS, +}; +use crate::sync::CommitId; +use core::cmp::Ordering; +use std::collections::HashSet; + +/// Get the lane's color index, which cycles through the color palette. +fn lane_color(lane: usize) -> LaneIndex { + LaneIndex::from(lane % MAX_LANE_COLORS) +} + +/// What the walk learned about one commit, indexed by its +/// [`CommitAlias`]. Aliases are dense `0..n`, so the facts live in a +/// flat vector instead of a map. +#[derive(Clone, Copy, Debug, Default)] +struct CommitFacts { + /// Already folded into the buffer; cannot be awaited by a new lane. + processed: bool, + + /// Derived from Git's parent count, independently of drawable edges. + is_merge: bool, + + /// The drawable second parent at the far end of the merge bridge. + /// It may share a lane that already leads to the same parent. + second_parent: Option, +} + +#[derive(Default)] +pub struct GraphWalker { + pub buffer: Buffer, + pub oids: GraphOids, + + /// What the walk knows about each commit, indexed by alias. + facts: Vec, +} + +impl GraphWalker { + pub fn new() -> Self { + Self::default() + } + + fn facts(&self, alias: CommitAlias) -> CommitFacts { + self.facts.get(*alias).copied().unwrap_or_default() + } + + fn facts_mut(&mut self, alias: CommitAlias) -> &mut CommitFacts { + let index = *alias; + if self.facts.len() <= index { + self.facts.resize(index + 1, CommitFacts::default()); + } + &mut self.facts[index] + } + + /// Mint the drawable alias for a parent commit, or `None` if the + /// walk already passed it. + fn mint_drawable_parent( + &mut self, + id: &CommitId, + ) -> Option { + let alias = self.oids.get_or_insert(id); + (!self.facts(alias).processed).then_some(UnwalkedAlias(alias)) + } + + /// The parents this commit draws an edge to: the first two + /// distinct parents the walk has not already passed. + /// + /// A row has at most one merge bridge. Duplicated, self-naming, + /// and already-walked parents must not leave uncloseable lanes. + fn drawable_parents( + &mut self, + parents: &[CommitId], + ) -> [Option; 2] { + let mut drawn: [Option; 2] = [None, None]; + let mut found = 0; + + for parent_id in parents { + if found == drawn.len() { + break; + } + + let Some(parent) = self.mint_drawable_parent(parent_id) + else { + continue; + }; + + let repeated = drawn[..found] + .iter() + .flatten() + .any(|seen| **seen == *parent); + if repeated { + continue; + } + + drawn[found] = Some(parent); + found += 1; + } + + drawn + } + + pub fn process( + &mut self, + commit_id: CommitId, + parents: &[CommitId], + ) { + let commit_alias = self.oids.get_or_insert(&commit_id); + + // Mark the commit walked before resolving its parents, so that + // a parent entry naming the commit itself is turned down like + // any other already-walked parent instead of opening a lane + // that waits for the commit which just closed it. + self.facts_mut(commit_alias).processed = true; + + let [first_parent, second_parent] = + self.drawable_parents(parents); + + let second = second_parent.map(|second| *second); + let facts = self.facts_mut(commit_alias); + facts.is_merge = parents.len() > 1; + facts.second_parent = second; + + self.buffer + .update(commit_alias, first_parent, second_parent); + } + + /// Number of commits already folded into the graph buffer. + pub const fn processed_commit_count(&self) -> usize { + self.buffer.deltas.len() + } + + pub fn compute_rows( + &self, + commit_range: &[CommitId], + global_start_index: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Vec { + if commit_range.is_empty() { + return Vec::new(); + } + + // Include a predecessor for bridge routing. At the beginning + // of the walk there is no predecessor, so the offset is zero. + let snapshot_start_index = + global_start_index.saturating_sub(1); + let snapshot_end_index = + global_start_index + commit_range.len() - 1; + let snapshots = self + .buffer + .decompress(snapshot_start_index, snapshot_end_index); + let index_offset = global_start_index - snapshot_start_index; + + commit_range + .iter() + .enumerate() + .map(|(range_index, commit_id)| { + let snapshot_index = range_index + index_offset; + + let current_snapshot = snapshots + .get(snapshot_index) + .map(Vec::as_slice) + .unwrap_or_default(); + + let previous_snapshot = snapshot_index + .checked_sub(1) + .and_then(|index| snapshots.get(index)) + .map(Vec::as_slice); + + self.render_row( + commit_id, + current_snapshot, + previous_snapshot, + branch_tips, + stashes, + head_id, + ) + }) + .collect() + } + + fn draw_merge_bridge( + lanes: &mut [Cell], + merge_bridge: Option<(usize, usize)>, + commit_lane: usize, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) { + let Some((source_lane, target_lane)) = merge_bridge else { + return; + }; + // `drawable_parents` keeps the two parents distinct, so the + // search in `calculate_merge_bridge` cannot land back on the + // merge's own lane. The guard stays because the alternative + // is overwriting the commit marker with a zero-length span. + if source_lane == target_lane { + return; + } + + let destination_lane = if source_lane == commit_lane { + target_lane + } else { + source_lane + }; + let connection_color = lane_color(destination_lane); + + let continues_upwards = Self::lane_continues_upwards( + destination_lane, + current_snapshot, + previous_snapshot, + ); + + let target_directions = Self::calculate_merge_directions( + commit_lane, + destination_lane, + continues_upwards, + ); + + // Replace the plain vertical fill with the precise corner/junction + lanes[destination_lane] = + Cell::line(target_directions, connection_color, false); + + Self::draw_bridge_span( + lanes, + source_lane, + target_lane, + connection_color, + ); + } + + fn draw_branching_lanes( + lanes: &mut Vec, + branching_lanes: &[usize], + commit_lane: usize, + ) -> Vec<(LaneIndex, LaneIndex)> { + branching_lanes + .iter() + .map(|&branch_lane| { + let start_lane = + std::cmp::min(branch_lane, commit_lane); + let end_lane = + std::cmp::max(branch_lane, commit_lane); + + Self::ensure_lane_capacity(lanes, end_lane); + + let connection_color = lane_color(branch_lane); + Self::draw_bridge_span( + lanes, + start_lane, + end_lane, + connection_color, + ); + + let branch_directions = + Self::calculate_branch_directions( + branch_lane, + start_lane, + end_lane, + ); + lanes[branch_lane] + .overlay(branch_directions, connection_color); + + ( + LaneIndex::from(start_lane), + LaneIndex::from(end_lane), + ) + }) + .collect() + } + + /// Whether the lane in `target_lane` is the same lane the row above + /// had there, which is what lets a bridge end draw a junction into + /// it instead of a corner. + fn lane_continues_upwards( + target_lane: usize, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> bool { + let Some(previous_snapshot) = previous_snapshot else { + return false; + }; + + let occupant = |snapshot: &[Option]| { + snapshot.get(target_lane).copied().flatten() + }; + + let current = occupant(current_snapshot); + current.is_some() && occupant(previous_snapshot) == current + } + + /// Directions at the receiving end of a merge bridge. + fn calculate_merge_directions( + commit_lane: usize, + target_lane: usize, + continues_upwards: bool, + ) -> Directions { + let mut directions = Directions::DOWN; + + if continues_upwards { + directions |= Directions::UP; + } + + match target_lane.cmp(&commit_lane) { + Ordering::Greater => directions |= Directions::LEFT, + Ordering::Less => directions |= Directions::RIGHT, + Ordering::Equal => {} + } + + directions + } + + fn calculate_branch_directions( + branch_lane: usize, + start_lane: usize, + end_lane: usize, + ) -> Directions { + let mut directions = Directions::UP; + + if branch_lane == end_lane { + directions |= Directions::LEFT; + } + if branch_lane == start_lane { + directions |= Directions::RIGHT; + } + + directions + } + + fn ensure_lane_capacity( + lanes: &mut Vec, + required_index: usize, + ) { + if lanes.len() <= required_index { + lanes.resize(required_index + 1, Cell::default()); + } + } + + pub fn render_row( + &self, + commit_id: &CommitId, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> GraphRow { + let commit_alias = self.oids.get(commit_id); + let head_alias = head_id.and_then(|id| self.oids.get(id)); + let facts = commit_alias + .map_or_else(CommitFacts::default, |alias| { + self.facts(alias) + }); + + let commit_lane = + Self::find_commit_lane(current_snapshot, commit_alias); + let is_merge = facts.is_merge; + let is_branch_tip = branch_tips.contains(commit_id); + let is_stash = stashes.contains(commit_id); + + let branching_lanes = Self::find_branching_lanes( + current_snapshot, + previous_snapshot, + ); + + let merge_bridge = + facts.second_parent.and_then(|parent_alias| { + commit_lane.and_then(|cl| { + Self::calculate_merge_bridge( + current_snapshot, + cl, + parent_alias, + ) + }) + }); + + let mut lanes: Vec = current_snapshot + .iter() + .enumerate() + .map(|(lane_index, chunk_option)| { + let Some(chunk) = chunk_option else { + return Cell::default(); + }; + + if commit_alias.is_some() + && chunk.alias() == commit_alias + { + let connection = + Self::determine_commit_connection( + is_stash, + is_merge, + is_branch_tip, + ); + return Cell::commit( + connection, + lane_color(lane_index), + ); + } + + Self::determine_passthrough_connection( + chunk, + head_alias, + lane_color(lane_index), + ) + }) + .collect(); + + let branches = commit_lane.map_or_else(Vec::new, |cl| { + Self::draw_merge_bridge( + &mut lanes, + merge_bridge, + cl, + current_snapshot, + previous_snapshot, + ); + + Self::draw_branching_lanes( + &mut lanes, + &branching_lanes, + cl, + ) + }); + + GraphRow { + commit_lane: LaneIndex::from(commit_lane.unwrap_or(0)), + is_merge, + is_branch_tip, + is_stash, + lanes: lanes.into_iter().map(Cell::finish).collect(), + merge_bridge: merge_bridge.map(|(source, target)| { + (LaneIndex::from(source), LaneIndex::from(target)) + }), + branches, + } + } + + /// Locates the primary lane for the current commit. + /// + /// Returns `None` if `commit_alias` is `None` or no matching lane is found. + fn find_commit_lane( + current_snapshot: &[Option], + commit_alias: Option, + ) -> Option { + let target_alias = commit_alias?; + + current_snapshot.iter().position(|slot| { + slot.as_ref().is_some_and(|chunk| { + chunk.alias() == Some(target_alias) + }) + }) + } + + /// Computes the span (min, max) between the commit's lane and its second parent's lane. + fn calculate_merge_bridge( + current_snapshot: &[Option], + commit_lane: usize, + second_parent_alias: CommitAlias, + ) -> Option<(usize, usize)> { + lane_awaiting_parent(current_snapshot, second_parent_alias) + .map(|target_lane| { + ( + commit_lane.min(target_lane), + commit_lane.max(target_lane), + ) + }) + } + + /// Columns whose lane drew a line into the row above and has no + /// occupant now: each one closed into this row's commit and needs + /// a bridge drawn across to its lane. + /// + /// A column the row above held a root commit in is not one of + /// them. [`LaneSlot::Settled`] draws a marker and no line + /// downward, so there would be nothing above for the bridge to + /// join, and drawing one would invent an edge between two + /// unrelated histories. + fn find_branching_lanes( + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> Vec { + let Some(previous) = previous_snapshot else { + return Vec::new(); + }; + + previous + .iter() + .enumerate() + .filter_map(|(index, previous_chunk)| { + (previous_chunk + .is_some_and(|chunk| chunk.awaits().is_some()) + && current_snapshot + .get(index) + .is_none_or(Option::is_none)) + .then_some(index) + }) + .collect() + } + + /// Determines the correct node type for the active commit lane. + const fn determine_commit_connection( + is_stash: bool, + is_merge: bool, + is_branch_tip: bool, + ) -> ConnectionType { + match (is_stash, is_merge, is_branch_tip) { + (true, _, _) => ConnectionType::CommitStash, + (_, true, _) => ConnectionType::CommitMerge, + (_, _, true) => ConnectionType::CommitBranch, + _ => ConnectionType::CommitNormal, + } + } + + /// Determines the correct vertical line style for non-commit passthrough lanes. + fn determine_passthrough_connection( + chunk: &LaneSlot, + head_alias: Option, + color: LaneIndex, + ) -> Cell { + // A settled lane draws nothing below its commit. + if matches!(chunk, LaneSlot::Settled { .. }) { + return Cell::default(); + } + + // Dot the segment of any lane, not just lane 0, that is + // heading toward HEAD: HEAD is not guaranteed to sit on the + // leftmost lane, e.g. once earlier lanes have closed and a + // later one has taken their place. + let is_dotted = + head_alias.is_some() && chunk.awaits() == head_alias; + + Cell::line( + Directions::UP | Directions::DOWN, + color, + is_dotted, + ) + } + + /// Lay the horizontal run of a bridge over the lanes strictly + /// between its two ends, merging with whatever each cell already + /// shows. + /// + /// These cells are passed *through*, never joined — a bridge's two + /// ends are drawn by the callers — so the run is recorded as a + /// crossing. That is what keeps an unrelated lane drawn whole + /// underneath it, and what stops it from swallowing a junction + /// another bridge left in the same cell. + fn draw_bridge_span( + lanes: &mut [Cell], + from: usize, + to: usize, + color: LaneIndex, + ) { + for lane in lanes.iter_mut().take(to).skip(from + 1) { + lane.overlay(Directions::CROSS, color); + } + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod real_repository; diff --git a/asyncgit/src/graph/walker/real_repository.rs b/asyncgit/src/graph/walker/real_repository.rs new file mode 100644 index 0000000000..17877c4c96 --- /dev/null +++ b/asyncgit/src/graph/walker/real_repository.rs @@ -0,0 +1,44 @@ +//! Holds a real repository's history to the same invariants as the +//! tests above. +//! +//! Every bug these invariants have caught so far was reachable from a +//! generated topology once the generator knew to produce the right +//! shape — but two of them were found here first, on a repository +//! busy enough to stumble into states the generator was not reaching. +//! Ignored by default, because it needs a checkout to point at: +//! +//! ```text +//! git -C rev-list --parents --topo-order --max-count=20000 HEAD > /tmp/parents +//! GITUI_GRAPH_PARENTS=/tmp/parents cargo test -p asyncgit \ +//! graph::walker::real_repository -- --ignored --nocapture +//! ``` +use crate::graph::harness::Scene; +use crate::sync::CommitId; +use std::collections::HashSet; + +#[test] +#[ignore = "needs GITUI_GRAPH_PARENTS; see the module docs"] +fn holds_every_invariant() { + let path = std::env::var("GITUI_GRAPH_PARENTS").expect( + "set GITUI_GRAPH_PARENTS to a rev-list --parents output file", + ); + let text = std::fs::read_to_string(&path) + .expect("GITUI_GRAPH_PARENTS names a readable file"); + + let history = text + .lines() + .map(|line| { + let mut ids = line.split_whitespace().map(|id| { + CommitId::from_str_unchecked(id) + .expect("rev-list emits valid object ids") + }); + let commit = ids.next().expect("a line names its commit"); + (commit, ids.collect()) + }) + .collect::>(); + + let total = history.len(); + assert!(total > 0, "the repository history is empty"); + Scene::walk_ids(history, HashSet::new(), HashSet::new(), None); + eprintln!("{total} commits drawn; every invariant holds"); +} diff --git a/asyncgit/src/graph/walker/tests.rs b/asyncgit/src/graph/walker/tests.rs new file mode 100644 index 0000000000..b39e44089c --- /dev/null +++ b/asyncgit/src/graph/walker/tests.rs @@ -0,0 +1,481 @@ +use super::*; +use crate::graph::harness::{oid, walk_generated, Scene, Shape}; + +#[test] +fn linear_history() { + Scene::walk(&[(1, &[2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["o", "o", "o"]); +} + +#[test] +fn history_with_multiple_roots() { + // Two independent commit chains, each terminating in its own + // root (a commit with no parents), keep separate lanes. A root + // draws a marker and no line below it, so handing its column + // to the next history would stack two commit markers in one + // column — which is exactly how a lane draws two consecutive + // commits, and would read as joining the two histories. + let scene = + Scene::walk(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); + scene.assert_drawing(&["o", "o", " o", " o"]); + scene.assert_fully_settled(); +} + +#[test] +fn simple_merge() { + // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 + Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["M━┓", "o ┃", "┃ o", "o━┛"]); +} + +#[test] +fn merge_into_tracked_lane_continues_through_corner() { + // 3's merge line joins lane 1 which keeps flowing to 5, + // so the corner must be a junction (┫), not a dead end (┓) + Scene::walk(&[ + (1, &[3]), + (2, &[5]), + (3, &[4, 5]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ]) + .assert_drawing(&["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"]); +} + +#[test] +fn merging_a_shared_second_parent_preserves_the_other_lane() { + // Commit 2 reuses commit 1's lane for parent 4. When 4 merges + // parent 6, its bridge must join commit 2's continuing lane. + Scene::walk(&[ + (1, &[4]), + (2, &[6, 4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┃ o", + "M━┫ ┃", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn merge_bridge_crosses_unrelated_lane() { + // 3 (lane 2) merges into 1's line (lane 0) while 2's line + // (lane 1) passes through: the crossed lane keeps its + // vertical instead of being cut by the bridge + Scene::walk(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┣━┃━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn overlapping_branch_bridges_keep_inner_corner() { + // lanes 1 and 2 both close into the commit on lane 0; the + // outer bridge passes through the inner corner (┻) instead + // of erasing it + Scene::walk(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); +} + +#[test] +fn nested_merges_keep_every_lane_in_its_column() { + // Commit 3 closes a branch while opening a merge of its own. + // The lane reserved for commit 1's second parent is already + // open in column 1, and has to stay there: moving it along to + // make room next to commit 3 would leave its line with + // nothing above it in the column it moved to, and would take + // commit 2's closing bridge with it. The merge's own lane goes + // at the right-hand end instead, and the bridge reaches it + // across the lanes in between. + Scene::walk(&[ + (1, &[3, 4]), + (2, &[3]), + (3, &[5, 6]), + (4, &[5]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]) + .assert_drawing(&[ + "M━┓", + "┃ ┃ o", + "M━┃━┻━┓", + "┃ o ┃", + "o━┛ ┃", + "┃ o", + "o━━━━━┛", + ]); +} + +#[test] +fn second_parent_lane_takes_a_column_left_blank_above() { + // Commit 4 closes the lane in column 1, so by the time commit + // 5 merges, the row above it draws nothing there. Starting the + // second parent's lane in that column keeps the bridge one + // step long instead of reaching past every lane still open. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[9]), + (4, &[5]), + (5, &[6, 7]), + (6, &[9]), + (7, &[9]), + (9, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┛ ┃", + "M━┓ ┃", + "o ┃ ┃", + "┃ o ┃", + "o━┻━┛", + ]); +} + +#[test] +fn second_parent_lane_skips_a_column_this_commit_freed() { + // Commit 4 both closes the lane in column 1 and opens a merge. + // Column 1 is vacant by the end of the step, but the row above + // still draws commit 2's lane there and this row draws its + // closing corner, so the merge's second parent cannot have it + // — taking it would put a new lane's corner where that edge + // ends and lose the edge. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━┻━┃━┓", + "o ┃ ┃", + "┃ ┃ o", + "┃ o ┃", + "o━━━┻━┛", + ]); +} + +#[test] +fn merge_bridges_to_the_lane_its_second_parent_already_has() { + // Commit 2 reuses the lane already heading for 5. Commit 3 must + // likewise reuse commit 2's lane for 6, even though 2 is a merge. + Scene::walk(&[ + (1, &[5]), + (2, &[6, 5]), + (3, &[7, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┣━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn a_bridge_crossing_another_bridges_junction_keeps_it() { + // Commit 4 does two things at once: it merges commit 6, whose + // lane is already open in column 1, and it closes the branch + // in column 2. The branch's bridge has to reach across column + // 1, which is where the merge's bridge ends. That cell joins + // up, down, left and right, and there is no way to draw it as + // a lane with something passing behind — drawing it as a plain + // vertical would take the merge edge out of the picture. + Scene::walk(&[ + (1, &[4]), + (2, &[6]), + (3, &[4]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━╋━┛", + "o ┃", + "┃ o", + "o━┛", + ]); +} + +#[test] +fn skewed_parent_before_child_leaves_no_phantom_lane() { + // Commit 2 is walked before commit 1, which lists it as a + // parent. The edge up to it cannot be drawn, and must not open + // a lane that waits for a commit the walk has gone past. The + // marker still says merge, because commit 1 is one. + Scene::walk(&[(2, &[3]), (1, &[2, 3]), (3, &[])]) + .assert_drawing(&["o", "┃ M", "o━┛"]); +} + +#[test] +fn complete_walk_settles_all_lanes() { + // Both parents of 1 appear before it in the walk (clock + // skew). After a complete walk no lane may still wait on a + // parent. + let scene = + Scene::walk(&[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]); + scene.assert_drawing(&["o", "┃ o", "┃ ┃ M", "o━┛"]); + scene.assert_fully_settled(); +} + +#[test] +fn duplicate_parent_entries_draw_one_edge() { + // A commit object may name the same parent twice. There is one + // edge to draw, and the second entry must not reserve a lane + // for a commit that the first entry's lane already receives. + Scene::walk(&[(1, &[2, 2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["M", "o", "o"]); +} + +#[test] +fn octopus_merge_draws_its_first_two_parents() { + // Support stops at two parents. The third parent's commit + // still appears, on a lane of its own with no edge into it, + // which is the honest picture of an edge this graph cannot + // show — not a lane left waiting for something. + let scene = Scene::walk(&[ + (1, &[2, 3, 4]), + (2, &[5]), + (3, &[5]), + (4, &[5]), + (5, &[]), + ]); + scene.assert_drawing(&["M━┓", "o ┃", "┃ o", "┃ ┃ o", "o━┻━┛"]); + scene.assert_fully_settled(); +} + +#[test] +fn a_commit_naming_itself_as_a_parent_opens_no_lane() { + let scene = Scene::walk(&[(1, &[1, 2]), (2, &[])]); + scene.assert_drawing(&["M", "o"]); + scene.assert_fully_settled(); +} + +#[test] +fn unwalked_parents_keep_their_lanes_open() { + // The log view shows a window onto a longer history, so a lane + // heading for a commit below the window stays open and draws + // off the bottom of the picture. + let scene = Scene::walk(&[(1, &[8]), (2, &[9])]); + scene.assert_drawing(&["o", "┃ o"]); + assert_eq!(scene.open_lane_count(), 2); +} + +#[test] +fn head_lane_is_dotted_in_any_column() { + // HEAD is not guaranteed to sit on the leftmost lane, so the + // dotted segment has to follow the lane heading for it. + Scene::walk_marked( + &[(1, &[3]), (2, &[4]), (3, &[5]), (4, &[5]), (5, &[])], + &[], + &[], + Some(4), + ) + .assert_drawing(&["o", "┃ o", "o ╏", "┃ o", "o━┛"]); +} + +#[test] +fn merging_head_dots_only_the_lane_heading_for_head() { + Scene::walk_marked( + &[ + (1, &[4]), + (2, &[5, 4]), + (3, &[6]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ], + &[], + &[], + Some(4), + ) + .assert_drawing(&[ + "o", + "┣━M", + "╏ ┃ o", + "o ┃ ┃", + "┃ o ┃", + "o━┻━┛", + ]); +} + +#[test] +fn branch_tip_and_stash_markers_leave_the_lines_alone() { + Scene::walk_marked( + &[(1, &[2]), (2, &[3]), (3, &[])], + &[1], + &[3], + None, + ) + .assert_drawing(&["*", "o", "*"]); +} + +#[test] +fn crossed_lane_keeps_own_color() { + let scene = Scene::walk(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + + // row of commit 3: lane 1 is crossed by the merge bridge but + // keeps both its vertical glyph and its own lane color + let crossed = scene.rows[2].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); +} + +#[test] +fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { + // 5 is the fork point for both 1 and 3 + let scene = Scene::walk(&[ + (1, &[5]), + (2, &[6]), + (3, &[5]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]); + scene.assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┃━┛", + "┃ o", + "o━┛", + ]); + + // row of commit 5: lane 1 is crossed by the branch bridge but + // keeps both its vertical glyph and its own lane color (its visual identity) + let crossed = scene.rows[3].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); +} + +#[test] +fn windows_decompress_across_checkpoint_boundaries() { + // Long enough to take several lane-state checkpoints, which is + // the path the log view uses for every scroll. The window + // comparison itself is part of every scene's invariants. + let scene = walk_generated(7, 250, Shape::MergeHeavy); + assert!( + scene.walker.buffer.checkpoints.len() >= 2, + "expected the walk to take checkpoints" + ); +} + +#[test] +fn a_wide_history_reports_every_column() { + // One lane per commit, none of them ever closing. A column + // index has to survive being reported: clamping it would + // relabel every lane past the clamp as the same column and + // put their edges in the wrong place. + const LANES: usize = 300; + + let history = (1..=LANES) + .map(|commit| (commit, vec![10_000 + commit])) + .collect::>(); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + let scene = Scene::walk(&borrowed); + let last = &scene.rows[LANES - 1]; + + assert_eq!(last.lane_count(), LANES); + assert_eq!(usize::from(last.commit_lane), LANES - 1); + assert_eq!(scene.open_lane_count(), LANES); +} + +#[test] +fn rows_requested_beyond_the_walk_draw_nothing() { + let mut walker = GraphWalker::new(); + walker.process(oid(1), &[oid(2)]); + + let rows = walker.compute_rows( + &[oid(2), oid(3)], + 1, + &HashSet::new(), + &HashSet::new(), + None, + ); + + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row.lanes.is_empty())); +} + +#[test] +fn generated_histories_hold_every_invariant() { + // Scene::walk holds each of these to every invariant, so this + // is the broad sweep behind the drawings above: the shapes + // cover long-lived branches, short topics, disjoint roots, + // mainlines that skip over the branches they merge, and the + // parent lists Git permits but a naive walk mishandles. + // + // Long enough that each history crosses a lane-state + // checkpoint, and that the shapes reach the states a short + // history never gets into. + const COMMITS: usize = 120; + const SEEDS: u64 = 10; + + for shape in Shape::ALL { + for seed in 0..SEEDS { + eprintln!("graph topology: {shape:?} seed {seed}"); + walk_generated(seed, COMMITS, shape); + } + } +} diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 98cc7238f9..ba6eea2525 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -8,7 +8,6 @@ It also provides synchronous Git operations. It wraps libraries like git2 and gix. */ -#![forbid(missing_docs)] #![deny( mismatched_lifetime_syntaxes, unused_imports, @@ -52,6 +51,7 @@ mod diff; mod error; mod fetch_job; mod filter_commits; +pub mod graph; mod progress; mod pull; mod push; diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 774d5140ef..af4fed90b0 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -1,14 +1,16 @@ use crate::{ error::Result, + graph::{GraphRow, GraphWalker}, sync::{ gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, + RepoPath, SharedCommitFilterFn, WalkEntry, }, AsyncGitNotification, Error, }; use crossbeam_channel::Sender; use scopetime::scope_time; use std::{ + collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -35,7 +37,7 @@ pub struct AsyncLogResult { /// pub duration: Duration, } -/// +/// Drives the background commit-log walker and exposes graph rows. pub struct AsyncLog { current: Arc>, current_head: Arc>>, @@ -45,6 +47,11 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, + /// All commits collected by the background thread, in walk order, + /// together with their parents. The graph walker reads these + /// lazily, only as far as the viewport requires. + walk_entries: Arc>>, + graph_walker: Arc>, } static LIMIT_COUNT: usize = 3000; @@ -70,7 +77,57 @@ impl AsyncLog { background: Arc::new(AtomicBool::new(false)), filter, partial_extract: AtomicBool::new(false), + walk_entries: Arc::new(Mutex::new(Vec::new())), + graph_walker: Arc::new(Mutex::new(GraphWalker::new())), + } + } + + /// Computes graph rows for `commit_slice` starting at `global_start`. + /// + /// Driven lazily. Processes only as many walked commits as the + /// viewport requires, resolving their parents on demand. + /// + /// Returns `None` when the background walk hasn't reached + /// `global_start + commit_slice.len()` yet. + pub fn get_graph_rows( + &self, + commit_slice: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Option> { + let needed_end = global_start + commit_slice.len(); + + let mut walker = self.graph_walker.lock().ok()?; + + { + let entries = self.walk_entries.lock().ok()?; + if entries.len() < needed_end { + return None; + } + + // the walker may already be ahead of the requested range + // (you know, scrolling up), so only feed it entries + // we know it is yet to have seen + let processed = + walker.processed_commit_count().min(needed_end); + + // Parents were already decoded by the walk and are carried + // along in `entries`, so folding a commit into the graph is + // pure in-memory work. + for (id, parents) in &entries[processed..needed_end] { + walker.process(*id, parents); + } } + + Some(walker.compute_rows( + commit_slice, + global_start, + branch_tips, + stashes, + head_id, + )) } /// @@ -165,6 +222,7 @@ impl AsyncLog { let sender = self.sender.clone(); let arc_pending = Arc::clone(&self.pending); let arc_background = Arc::clone(&self.background); + let arc_walk_entries = Arc::clone(&self.walk_entries); let filter = self.filter.clone(); let repo_path = self.repo.clone(); @@ -181,6 +239,7 @@ impl AsyncLog { &arc_current, &arc_background, &sender, + &arc_walk_entries, filter, ) .expect("failed to fetch"); @@ -198,6 +257,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_walk_entries: &Arc>>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -207,6 +267,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_walk_entries, ) }, |filter| { @@ -221,6 +282,9 @@ impl AsyncLog { ) } + /// A filtered walk yields a disconnected subset of the history, + /// which the graph cannot represent, so no topology entries are + /// collected here. fn fetch_helper_with_filter( repo_path: &RepoPath, arc_current: &Arc>, @@ -228,37 +292,17 @@ impl AsyncLog { sender: &Sender, filter: SharedCommitFilterFn, ) -> Result<()> { - let start_time = Instant::now(); - - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - let r = repo(repo_path)?; let mut walker = LogWalker::new(&r, LIMIT_COUNT)?.filter(Some(filter)); - loop { - entries.clear(); - let read = walker.read(&mut entries)?; - - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); - current.duration = start_time.elapsed(); - - if read == 0 { - break; - } - Self::notify(sender); - - let sleep_duration = - if arc_background.load(Ordering::Relaxed) { - SLEEP_BACKGROUND - } else { - SLEEP_FOREGROUND - }; - - thread::sleep(sleep_duration); - } + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + None, + )?; log::trace!("revlog visited: {}", walker.visited()); @@ -270,48 +314,73 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_walk_entries: &Arc>>, ) -> Result<()> { - let start_time = Instant::now(); - - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - let mut repo: gix::Repository = gix_repo(repo_path)?; let mut walker = LogWalkerWithoutFilter::new(&mut repo, LIMIT_COUNT)?; - loop { - entries.clear(); - let read = walker.read(&mut entries)?; + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + Some(arc_walk_entries), + )?; + + log::trace!("revlog visited: {}", walker.visited()); + Ok(()) + } + + /// Drives `read` in batches, publishing every batch's commit ids + /// to `arc_current` and, when given, moving the full entries, + /// both ids and parents; into `walk_entries` for the graph. + fn walk_loop( + mut read: impl FnMut(&mut Vec) -> Result, + arc_current: &Arc>, + arc_background: &Arc, + sender: &Sender, + walk_entries: Option<&Mutex>>, + ) -> Result<()> { + let start_time = Instant::now(); + let mut entries = Vec::with_capacity(LIMIT_COUNT); + + while read(&mut entries)? > 0 { + // publish let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); + current.commits.extend(entries.iter().map(|&(id, _)| id)); current.duration = start_time.elapsed(); - if read == 0 { - break; + if let Some(arc_walk_entries) = walk_entries { + arc_walk_entries.lock()?.append(&mut entries); } - Self::notify(sender); - - let sleep_duration = - if arc_background.load(Ordering::Relaxed) { - SLEEP_BACKGROUND - } else { - SLEEP_FOREGROUND - }; + entries.clear(); - thread::sleep(sleep_duration); + Self::notify(sender); + thread::sleep(Self::pause(arc_background)); } - log::trace!("revlog visited: {}", walker.visited()); + arc_current.lock()?.duration = start_time.elapsed(); Ok(()) } + /// The pause between batches + fn pause(arc_background: &Arc) -> Duration { + if arc_background.load(Ordering::Relaxed) { + SLEEP_BACKGROUND + } else { + SLEEP_FOREGROUND + } + } + fn clear(&self) -> Result<()> { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; self.partial_extract.store(false, Ordering::Relaxed); + self.walk_entries.lock()?.clear(); + *self.graph_walker.lock()? = GraphWalker::new(); Ok(()) } @@ -359,12 +428,14 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, &arc_current, &arc_background, &tx_git, + &arc_walk_entries, ); assert_eq!(result.unwrap(), ()); @@ -387,6 +458,7 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); std::env::set_var("GIT_DIR", git_dir); @@ -396,6 +468,7 @@ mod tests { &arc_current, &arc_background, &tx_git, + &arc_walk_entries, ); std::env::remove_var("GIT_DIR"); diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 772b73a040..921d98c730 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -10,6 +10,7 @@ use crate::{ }; use git2::{Commit, Error, Oid}; use scopetime::scope_time; +use smallvec::SmallVec; use unicode_truncate::UnicodeTruncateStr; /// identifies a single commit @@ -87,6 +88,15 @@ impl From for CommitId { } } +impl From> for CommitId { + fn from(object_id: gix::Id<'_>) -> Self { + #[allow(clippy::expect_used)] + let oid = Oid::from_bytes(object_id.as_bytes()).expect("`Oid::from_bytes(object_id.as_bytes())` is expected to never fail"); + + Self::new(oid) + } +} + impl From for CommitId { fn from(object_id: gix::ObjectId) -> Self { #[allow(clippy::expect_used)] @@ -122,6 +132,8 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// + pub parents: SmallVec<[CommitId; 2]>, } /// @@ -156,6 +168,11 @@ pub fn get_commits_info( author, time: c.time().seconds(), id: CommitId(c.id()), + parents: c + .parents() + .take(2) + .map(|p| CommitId(p.id())) + .collect(), } }) .collect::>(); @@ -190,6 +207,11 @@ pub fn get_commit_info( author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents: commit + .parent_ids() + .take(2) + .map(Into::into) + .collect(), }) } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 81a6fd321e..c144155a9f 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -2,11 +2,17 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; +use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, }; +/// One walked commit plus its parents, as decoded by the walk +/// itself, so consumers never need to touch the object database a +/// second time. +pub type WalkEntry = (CommitId, SmallVec<[CommitId; 4]>); + struct TimeOrderedCommit<'a>(Commit<'a>); impl Eq for TimeOrderedCommit<'_> {} @@ -69,11 +75,19 @@ impl<'a> LogWalker<'a> { Self { filter, ..self } } - /// - pub fn read(&mut self, out: &mut Vec) -> Result { + /// Reads up to `limit` commits and their parents, newest first. + /// Parents come straight from the already-parsed commit, at no + /// extra object-database cost. + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { + let parents: SmallVec<[CommitId; 4]> = + c.0.parent_ids().map(Into::into).collect(); + for p in c.0.parents() { self.visit(p); } @@ -87,7 +101,7 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(id); + out.push((id, parents)); } count += 1; @@ -156,12 +170,24 @@ impl<'a> LogWalkerWithoutFilter<'a> { self.visited } - /// - pub fn read(&mut self, out: &mut Vec) -> Result { + /// Reads up to `limit` commits and their parents, newest first. + /// The parents are already decoded by the walk, so carrying them + /// along costs nothing extra and we save a second traversal! + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { - out.push(info.id.into()); + out.push(( + info.id.into(), + info.parent_ids + .iter() + .copied() + .map(Into::into) + .collect(), + )); count += 1; @@ -209,12 +235,12 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walk = LogWalker::new(&repo, 1)?; - walk.read(&mut items).unwrap(); + walk.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, oid2); Ok(()) } @@ -234,20 +260,22 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walk = LogWalker::new(&repo, 100)?; - walk.read(&mut items).unwrap(); + walk.read(&mut entries).unwrap(); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = + entries.iter().map(|(id, _)| *id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); - assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, oid2); - let mut items = Vec::new(); - walk.read(&mut items).unwrap(); + let mut entries = Vec::new(); + walk.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -269,19 +297,21 @@ mod tests { let mut repo: gix::Repository = gix_repo(repo_path)?; let mut walk = LogWalkerWithoutFilter::new(&mut repo, 100)?; - let mut items = Vec::new(); - assert!(matches!(walk.read(&mut items), Ok(2))); + let mut entries = Vec::new(); + assert!(matches!(walk.read(&mut entries), Ok(2))); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = + entries.iter().map(|(id, _)| *id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); - assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, oid2); - let mut items = Vec::new(); - assert!(matches!(walk.read(&mut items), Ok(0))); + let mut entries = Vec::new(); + assert!(matches!(walk.read(&mut entries), Ok(0))); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -312,27 +342,27 @@ mod tests { let diff_contains_baz = diff_contains_file("baz".into()); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100)? .filter(Some(diff_contains_baz)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, second_commit_id); - let mut items = Vec::new(); - walker.read(&mut items).unwrap(); + let mut entries = Vec::new(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); let diff_contains_bar = diff_contains_file("bar".into()); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100)? .filter(Some(diff_contains_bar)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -358,14 +388,14 @@ mod tests { }), ); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, second_commit_id); let log_filter = filter_commit_by_search( LogFilterSearch::new(LogFilterSearchOptions { @@ -375,12 +405,12 @@ mod tests { }), ); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 2); + assert_eq!(entries.len(), 2); } } diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..a7b8074098 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -72,7 +72,7 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; +pub use logwalker::{LogWalker, LogWalkerWithoutFilter, WalkEntry}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, @@ -261,13 +261,13 @@ pub mod tests { r: &Repository, max_count: usize, ) -> Vec { - let mut commit_ids = Vec::::new(); + let mut entries = Vec::new(); LogWalker::new(r, max_count) .unwrap() - .read(&mut commit_ids) + .read(&mut entries) .unwrap(); - commit_ids + entries.into_iter().map(|(id, _)| id).collect() } /// Same as `repo_init`, but the repo is a bare repo (--bare) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index fd46376fe7..778c9784ae 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -772,6 +772,14 @@ mod tests { let key = home.join("key.pem"); let cert = home.join("cert.pem"); + let cert_config = home.join("openssl.cnf"); + // LibreSSL and OpenSSL ship different default extensions. Make + // this self-signed trust root a CA explicitly so gpgsm can + // validate it with either tool's default configuration. + std::fs::write( + &cert_config, + "[req]\ndistinguished_name=dn\n[dn]\n[extensions]\nbasicConstraints=critical,CA:TRUE\n", + )?; run( "openssl", &[ @@ -788,6 +796,10 @@ mod tests { "3650", "-subj", &format!("/CN=gitui test/emailAddress={email}"), + "-config", + cert_config.to_str().unwrap(), + "-extensions", + "extensions", ], ); // gpgsm's PKCS#12 reader accepts different ciphers across versions, so diff --git a/filetreelist/src/lib.rs b/filetreelist/src/lib.rs index 3e80065aba..74ccf6aed4 100644 --- a/filetreelist/src/lib.rs +++ b/filetreelist/src/lib.rs @@ -1,4 +1,3 @@ -// #![forbid(missing_docs)] #![forbid(unsafe_code)] #![deny( mismatched_lifetime_syntaxes, diff --git a/src/app.rs b/src/app.rs index ddb157af3f..435b5eedde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,9 +138,9 @@ impl Environment { use crossbeam_channel::unbounded; Self { queue: Queue::new(), - theme: Default::default(), - key_config: Default::default(), - repo: RefCell::new(RepoPath::Path(Default::default())), + theme: Rc::default(), + key_config: Rc::default(), + repo: RefCell::new(RepoPath::Path(PathBuf::default())), options: Rc::new(RefCell::new(Options::test_env())), sender_git: unbounded().0, sender_app: unbounded().0, diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e97754ecb4..78cfdab2dd 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -13,9 +13,12 @@ use crate::{ ui::{calc_scroll_top, draw_scrollbar, Orientation}, }; use anyhow::Result; -use asyncgit::sync::{ - self, checkout_commit, BranchDetails, BranchInfo, CommitId, - RepoPathRef, Tags, +use asyncgit::{ + graph::{ConnectionType, GraphRow}, + sync::{ + self, checkout_commit, BranchDetails, BranchInfo, CommitId, + RepoPathRef, Tags, + }, }; use chrono::{DateTime, Local}; use crossterm::event::Event; @@ -23,20 +26,30 @@ use indexmap::IndexSet; use itertools::Itertools; use ratatui::{ layout::{Alignment, Rect}, - style::Style, + style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; +use std::borrow::Cow; +use std::collections::HashSet; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, - time::Instant, + cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; -/// +const GRAPH_COLORS: &[Color] = &[ + Color::Blue, + Color::Yellow, + Color::Magenta, + Color::Cyan, + Color::Green, + Color::Red, +]; + +/// Renders the commit log with a side-bar graph. pub struct CommitList { repo: RepoPathRef, title: Box, @@ -58,6 +71,8 @@ pub struct CommitList { theme: SharedTheme, queue: Queue, key_config: SharedKeyConfig, + show_graph: bool, + graph_col_width: Cell, } impl CommitList { @@ -81,9 +96,33 @@ impl CommitList { queue: env.queue.clone(), key_config: env.key_config.clone(), title: title.into(), + show_graph: true, + graph_col_width: Cell::new(0), } } + /// Whether the commit graph column is currently visible. + pub const fn is_graph_visible(&self) -> bool { + self.show_graph + } + + /// Whether the loaded window already has up-to-date graph rows. + pub const fn is_graph_ready(&self) -> bool { + self.items.graph_ready + } + + /// + pub fn get_loaded_slice(&self) -> (Vec, usize) { + let offset = self.items.index_offset(); + let ids = self.items.iter().map(|e| e.id).collect(); + (ids, offset) + } + + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + self.items.set_graph_rows(rows); + } + /// pub const fn tags(&self) -> Option<&Tags> { self.tags.as_ref() @@ -175,6 +214,20 @@ impl CommitList { } } + /// + pub const fn local_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.local_branches + } + + /// + pub const fn remote_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.remote_branches + } + /// pub fn set_local_branches( &mut self, @@ -188,6 +241,9 @@ impl CommitList { .or_default() .push(local_branch); } + + // branch tips and head are baked into graph rows + self.items.graph_ready = false; } /// @@ -203,6 +259,9 @@ impl CommitList { .or_default() .push(remote_branch); } + + // branch tips are baked into graph rows + self.items.graph_ready = false; } /// @@ -439,6 +498,38 @@ impl CommitList { } } + fn build_graph_spans<'a>( + row: &'a GraphRow, + graph_col_width: usize, + empty_lanes: &std::collections::HashSet, + ) -> Vec> { + let mut spans = Vec::new(); + + for (lane_index, column) in row.columns().enumerate() { + if empty_lanes.contains(&lane_index) { + continue; + } + for cell in column { + spans.push(match cell { + Some((connection, color)) => Span::styled( + connection.symbol(), + Style::default() + .fg(GRAPH_COLORS[usize::from(color) + % GRAPH_COLORS.len()]), + ), + None => Span::raw(" "), + }); + } + } + + let current_width = spans.len(); + for _ in current_width..graph_col_width { + spans.push(Span::raw(" ")); + } + + spans + } + #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -451,17 +542,21 @@ impl CommitList { width: usize, now: DateTime, marked: Option, + empty_lanes: &std::collections::HashSet, ) -> Line<'a> { let mut txt: Vec = Vec::with_capacity( ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, ); + if self.show_graph { + self.add_graph_spans(e, &mut txt, empty_lanes); + } + let normal = !self.items.highlighting() || (self.items.highlighting() && e.highlighted); - let splitter_txt = Cow::from(symbol::EMPTY_SPACE); let splitter = Span::styled( - splitter_txt, + Cow::from(symbol::EMPTY_SPACE), if normal { theme.text(true, selected) } else { @@ -482,40 +577,80 @@ impl CommitList { txt.push(splitter.clone()); } - let style_hash = if normal { - theme.commit_hash(selected) - } else { - theme.commit_unhighlighted() - }; - let style_time = if normal { - theme.commit_time(selected) - } else { - theme.commit_unhighlighted() - }; - let style_author = if normal { - theme.commit_author(selected) - } else { - theme.commit_unhighlighted() - }; - let style_tags = if normal { - theme.tags(selected) - } else { - theme.commit_unhighlighted() - }; - let style_branches = if normal { - theme.branch(selected, true) + Self::add_entry_details( + e, + &mut txt, + selected, + normal, + theme, + width, + now, + tags, + local_branches, + remote_branches, + &splitter, + ); + + Line::from(txt) + } + + fn add_graph_spans<'a>( + &self, + e: &'a LogEntry, + txt: &mut Vec>, + empty_lanes: &std::collections::HashSet, + ) { + if let Some(ref row) = e.graph { + txt.extend(Self::build_graph_spans( + row, + self.graph_col_width.get(), + empty_lanes, + )); } else { - theme.commit_unhighlighted() - }; - let style_msg = if normal { - theme.text(true, selected) + txt.push(Span::raw( + " ".repeat(self.graph_col_width.get()), + )); + } + txt.push(Span::raw(" ")); + } + + #[allow(clippy::too_many_arguments)] + fn add_entry_details<'a>( + e: &'a LogEntry, + txt: &mut Vec>, + selected: bool, + normal: bool, + theme: &Theme, + width: usize, + now: DateTime, + tags: Option, + local_branches: Option, + remote_branches: Option, + splitter: &Span<'a>, + ) { + let ( + style_hash, + style_time, + style_author, + style_tags, + style_branches, + style_msg, + ) = if normal { + ( + theme.commit_hash(selected), + theme.commit_time(selected), + theme.commit_author(selected), + theme.tags(selected), + theme.branch(selected, true), + theme.text(true, selected), + ) } else { - theme.commit_unhighlighted() + let s = theme.commit_unhighlighted(); + (s, s, s, s, s, s) }; // commit hash txt.push(Span::styled(Cow::from(&*e.hash_short), style_hash)); - txt.push(splitter.clone()); // commit timestamp @@ -523,7 +658,6 @@ impl CommitList { Cow::from(e.time_to_string(now)), style_time, )); - txt.push(splitter.clone()); let author_width = @@ -532,7 +666,6 @@ impl CommitList { // commit author txt.push(Span::styled(author, style_author)); - txt.push(splitter.clone()); // commit tags @@ -550,7 +683,7 @@ impl CommitList { txt.push(Span::styled(remote_branches, style_branches)); } - txt.push(splitter); + txt.push(splitter.clone()); let message_width = width.saturating_sub( txt.iter().map(|span| span.content.len()).sum(), @@ -561,65 +694,118 @@ impl CommitList { format!("{:message_width$}", e.msg), style_msg, )); - - Line::from(txt) } fn get_text(&self, height: usize, width: usize) -> Vec> { let selection = self.relative_selection(); - - let mut txt: Vec = Vec::with_capacity(height); - let now = Local::now(); - let any_marked = !self.marked.is_empty(); - for (idx, e) in self - .items + if self.show_graph { + let view = self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height); + + let max_lane = view + .clone() + .filter_map(|e| e.graph.as_ref()) + .map(|row| row.lanes.len()) + .max() + .unwrap_or(0); + + let empty_lanes: std::collections::HashSet = (0..max_lane) + .filter(|&index| { + view.clone().filter_map(|entry| entry.graph.as_ref()).all(|row| { + row.lanes.get(index).is_none_or(|conn| { + matches!(conn, None | Some((ConnectionType::MergeBridgeMid, _))) + }) + }) + }) + .collect(); + + self.graph_col_width.set( + ((max_lane.saturating_sub(empty_lanes.len())) * 2) + .max(2), + ); + + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &empty_lanes, + ) + } else { + self.graph_col_width.set(0); + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &HashSet::new(), + ) + } + } + + fn collect_entries( + &self, + height: usize, + width: usize, + selection: usize, + now: DateTime, + any_marked: bool, + empty_lanes: &HashSet, + ) -> Vec> { + self.items .iter() .skip(self.scroll_top.get()) .take(height) .enumerate() - { - let tags = - self.tags.as_ref().and_then(|t| t.get(&e.id)).map( - |tags| { + .map(|(index, entry)| { + let tags = self + .tags + .as_ref() + .and_then(|t| t.get(&entry.id)) + .map(|tags| { tags.iter() - .map(|t| format!("<{}>", t.name)) + .map(|tag| format!("<{}>", tag.name)) .join(" ") - }, - ); - - let local_branches = - self.local_branches.get(&e.id).map(|local_branch| { - local_branch - .iter() - .map(|local_branch| { - format!("{{{0}}}", local_branch.name) - }) - .join(" ") - }); - - let marked = if any_marked { - self.is_marked(&e.id) - } else { - None - }; - - txt.push(self.get_entry_to_add( - e, - idx + self.scroll_top.get() == selection, - tags, - local_branches, - self.remote_branches_string(e), - &self.theme, - width, - now, - marked, - )); - } - - txt + }); + + let local_branches = self + .local_branches + .get(&entry.id) + .map(|local_branch| { + local_branch + .iter() + .map(|branch| { + format!("{{{}}}", branch.name) + }) + .join(" ") + }); + + let marked = any_marked + .then(|| self.is_marked(&entry.id)) + .flatten(); + + self.get_entry_to_add( + entry, + index + self.scroll_top.get() == selection, + tags, + local_branches, + self.remote_branches_string(entry), + &self.theme, + width, + now, + marked, + empty_lanes, + ) + }) + .collect() } fn remote_branches_string(&self, e: &LogEntry) -> Option { @@ -863,6 +1049,12 @@ impl Component for CommitList { ) { self.checkout(); true + } else if key_match( + k, + self.key_config.keys.log_toggle_graph, + ) { + self.show_graph = !self.show_graph; + true } else { false }; @@ -890,6 +1082,11 @@ impl Component for CommitList { true, true, )); + out.push(CommandInfo::new( + strings::commands::log_toggle_graph(&self.key_config), + true, + true, + )); CommandBlocking::PassingOn } } @@ -922,6 +1119,8 @@ mod tests { std::path::PathBuf::default(), )), queue: Queue::default(), + show_graph: true, + graph_col_width: Cell::new(0), } } } @@ -956,6 +1155,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: Vec::default().into(), }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 @@ -1069,4 +1269,54 @@ mod tests { ))) ); } + + #[test] + fn test_build_graph_spans() { + let row = GraphRow { + commit_lane: 0.into(), + is_merge: false, + is_branch_tip: false, + is_stash: false, + lanes: vec![Some(( + ConnectionType::CommitNormal, + 0.into(), + ))], + merge_bridge: None, + branches: vec![], + }; + let spans = CommitList::build_graph_spans( + &row, + 1, + &std::collections::HashSet::new(), + ); + + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].content, Cow::from("o")); + assert_eq!(spans[1].content, Cow::from(" ")); + } + + #[test] + fn graph_spans_keep_bridge_color_when_empty_columns_are_hidden() { + let row = GraphRow { + commit_lane: 0.into(), + lanes: vec![ + Some((ConnectionType::CommitMerge, 0.into())), + Some((ConnectionType::MergeBridgeMid, 2.into())), + Some((ConnectionType::MergeBridgeStart, 2.into())), + ], + merge_bridge: Some((0.into(), 2.into())), + ..GraphRow::default() + }; + let spans = CommitList::build_graph_spans( + &row, + 6, + &std::collections::HashSet::from([1]), + ); + let text: String = + spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "M━┓ "); + assert_eq!(spans[0].style.fg, Some(GRAPH_COLORS[0])); + assert_eq!(spans[1].style.fg, Some(GRAPH_COLORS[2])); + assert_eq!(spans[2].style.fg, Some(GRAPH_COLORS[2])); + } } diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..48935ca777 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -22,7 +22,7 @@ use std::{borrow::Cow, cell::Cell, path::Path}; //TODO: use new `filetreelist` crate -/// +/// Renders the working-tree status as a tree. #[allow(clippy::struct_excessive_bools)] pub struct StatusTreeComponent { title: String, diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 9e0706226e..ad1a7060ec 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,4 +1,7 @@ -use asyncgit::sync::{CommitId, CommitInfo}; +use asyncgit::{ + graph::GraphRow, + sync::{CommitId, CommitInfo}, +}; use chrono::{DateTime, Duration, Local, Utc}; use indexmap::IndexSet; use std::{rc::Rc, slice::Iter}; @@ -20,6 +23,7 @@ pub struct LogEntry { pub hash_short: BoxStr, pub id: CommitId, pub highlighted: bool, + pub graph: Option, } impl From for LogEntry { @@ -54,6 +58,7 @@ impl From for LogEntry { hash_short, id: c.id, highlighted: false, + graph: None, } } } @@ -78,12 +83,14 @@ impl LogEntry { } } -/// +/// A batch of parsed log entries with an index offset. #[derive(Default)] pub struct ItemBatch { index_offset: Option, items: Vec, highlighting: bool, + pub graph_ready: bool, + pub max_lane: usize, } impl ItemBatch { @@ -115,6 +122,8 @@ impl ItemBatch { pub fn clear(&mut self) { self.items.clear(); self.index_offset = None; + self.graph_ready = false; + self.max_lane = 0; } /// insert new batch of items @@ -143,6 +152,17 @@ impl ItemBatch { } } + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + let mut max: usize = 0; + for (entry, row) in self.items.iter_mut().zip(rows) { + max = max.max(row.lane_count()); + entry.graph = Some(row); + } + self.max_lane = max; + self.graph_ready = true; + } + /// returns `true` if we should fetch updated list of items pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { let want_min = diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..5a67e0ee4a 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -225,8 +225,8 @@ mod tests { settings.add_filter(r" *\[…\]\S+-insta/?", "[TEMP_FILE]"); // Linux Temp Folder settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); - // Commit ids that follow a vertical bar - settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] "); + // Commit ids that follow a vertical bar (may be preceded by graph glyphs) + settings.add_filter(r"│[^<]{0,10}[a-f0-9]{7} ", "│[AAAAA] "); let _bound = settings.bind_to_scope(); } } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..6d75fae73c 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -129,6 +129,7 @@ pub struct KeysList { pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, pub goto_line: GituiKeyEvent, + pub log_toggle_graph: GituiKeyEvent, } #[rustfmt::skip] @@ -227,6 +228,7 @@ impl Default for KeysList { commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), + log_toggle_graph: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), } } } diff --git a/src/main.rs b/src/main.rs index fd662950a2..cb5fc69be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,7 +114,7 @@ type Terminal = ratatui::Terminal>; static TICK_INTERVAL: Duration = Duration::from_secs(5); static SPINNER_INTERVAL: Duration = Duration::from_millis(80); -/// +/// Events sent to the event-loop to wake up the UI. #[derive(Clone)] pub enum QueueEvent { Tick, diff --git a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap index bbdd5be8a4..ea57f3165b 100644 --- a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap +++ b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap @@ -6,7 +6,7 @@ snapshot_kind: text " Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " " ──────────────────────────────────────────────────────────────────────────────────────── " "┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐" -"│[AAAAA] <1m ago name initial █" +"│[AAAAA] <1m ago name initial █" "│ ║" "│ ║" "│ ║" @@ -14,4 +14,4 @@ snapshot_kind: text "│ ║" "│ ║" "└────────────────────────────────────────────────────────────────────────────────────────┘" -"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap new file mode 100644 index 0000000000..bab83a0898 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 359 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│[COMMIT] <1m ago name commit B █" +"│[COMMIT] <1m ago name commit A ║" +"│[COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_linear.snap b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap new file mode 100644 index 0000000000..a03daefa14 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 341 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│o [COMMIT] <1m ago name commit B █" +"│o [COMMIT] <1m ago name commit A ║" +"│o [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap new file mode 100644 index 0000000000..b4033ba5ba --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap @@ -0,0 +1,19 @@ +--- +source: src/gitui.rs +assertion_line: 375 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 4/4──────────────────────────────────────────────────────────────────────────────┐" +"│M━┓ [COMMIT] <1m ago name merge C into main █" +"│o ┃ [COMMIT] <1m ago name commit A ║" +"│┃ o [COMMIT] <1m ago name commit C ║" +"│o━┛ [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..d2d4da880d 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1608,6 +1608,19 @@ pub mod commands { ) } + pub fn log_toggle_graph( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Graph [{}]", + key_config.get_hint(key_config.keys.log_toggle_graph), + ), + "toggle commit graph", + CMD_GROUP_LOG, + ) + } + pub fn reset_commit(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 9200c93f00..ef2d6dc648 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -33,6 +33,7 @@ use ratatui::{ Frame, }; use std::{ + collections::HashSet, rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, @@ -59,7 +60,7 @@ enum LogSearch { Results(LogSearchResult), } -/// +/// Top-level revlog tab that ties together the commit list, details, and search. pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, @@ -134,6 +135,12 @@ impl Revlog { self.list .refresh_extend_data(self.git_log.extract_items()?); + if self.list.is_graph_visible() + && !self.list.is_graph_ready() + { + self.update_graph_rows(); + } + self.git_tags.request(Duration::from_secs(3), false)?; if self.commit_details.is_visible() { @@ -150,6 +157,47 @@ impl Revlog { Ok(()) } + /// Computes graph rows for the current commit window. Rows + /// don't depend on the selection + /// so its is skipped while + /// [`CommitList::is_graph_ready`] holds the bag. + fn update_graph_rows(&mut self) { + let (slice, global_start) = self.list.get_loaded_slice(); + if slice.is_empty() { + return; + } + + let head_id = self.list.local_branches().iter().find_map( + |(id, branches)| { + let has_head = branches.iter().any(|branch| { + branch.local_details().is_some_and(|b| b.is_head) + }); + has_head.then_some(*id) + }, + ); + + let branch_tips: HashSet<_> = self + .list + .local_branches() + .keys() + .chain(self.list.remote_branches().keys()) + .copied() + .collect(); + + // TODO: include stashes (heh) + let stashes = HashSet::new(); + + if let Some(rows) = self.git_log.get_graph_rows( + &slice, + global_start, + &branch_tips, + &stashes, + head_id.as_ref(), + ) { + self.list.set_graph_rows(rows); + } + } + /// pub fn update_git( &mut self,