diff --git a/Cargo.lock b/Cargo.lock index 1f54dd9168e..238b3e500a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,15 +278,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -294,9 +294,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -369,9 +369,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -417,9 +417,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -445,6 +445,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -496,9 +507,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] @@ -557,9 +568,9 @@ dependencies = [ [[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", @@ -607,6 +618,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -658,9 +679,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -830,9 +851,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -854,9 +875,9 @@ dependencies = [ [[package]] name = "curl-sys" -version = "0.4.87+curl-8.19.0" +version = "0.4.88+curl-8.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a460380f0ef783703dcbe909107f39c162adeac050d73c850055118b5b6327" +checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0" dependencies = [ "cc", "libc", @@ -865,7 +886,7 @@ dependencies = [ "pkg-config", "rustls-ffi", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -904,9 +925,9 @@ dependencies = [ [[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", @@ -988,19 +1009,19 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[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", @@ -1030,9 +1051,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -1049,18 +1070,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "env_filter" version = "1.0.1" @@ -1178,13 +1187,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1306,6 +1314,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1326,6 +1345,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1380,6 +1400,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -1493,6 +1514,7 @@ dependencies = [ "gix-revwalk", "gix-sec", "gix-shallow", + "gix-stash", "gix-status", "gix-submodule", "gix-tempfile", @@ -1905,7 +1927,7 @@ name = "gix-hashtable" version = "0.15.1" dependencies = [ "gix-hash", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "parking_lot", ] @@ -1935,7 +1957,7 @@ dependencies = [ "gix-hash", "gix-imara-diff", "gix-object", - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] @@ -1960,7 +1982,7 @@ dependencies = [ "gix-traverse", "gix-utils", "gix-validate", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "insta", "itoa", "libc", @@ -2358,6 +2380,31 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-stash" +version = "0.0.0" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-diff", + "gix-features", + "gix-filter", + "gix-hash", + "gix-index", + "gix-lock", + "gix-merge", + "gix-object", + "gix-odb", + "gix-path", + "gix-ref", + "gix-testtools", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "thiserror 2.0.18", +] + [[package]] name = "gix-status" version = "0.31.0" @@ -2618,9 +2665,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2683,9 +2730,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -2724,46 +2771,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "hickory-proto", "idna", "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", "once_cell", - "rand", + "prefix-trie", + "rand 0.10.1", "ring", "thiserror 2.0.18", "tinyvec", - "tokio", "tracing", "url", ] [[package]] name = "hickory-resolver" -version = "0.25.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni 0.22.4", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand", + "rand 0.10.1", "resolv-conf", "smallvec", + "system-configuration", "thiserror 2.0.18", "tokio", "tracing", @@ -2771,9 +2842,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2810,24 +2881,24 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "human_format" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3b1f728c459d27b12448862017b96ad4767b1ec2ec5e6434e99f1577f085b8" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3005,9 +3076,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3020,7 +3091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3098,14 +3169,7 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ - "memchr", "serde", ] @@ -3177,9 +3241,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -3187,14 +3251,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -3302,9 +3366,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -3376,18 +3440,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags 2.11.1", - "libc", - "plain", - "redox_syscall 0.7.4", -] - [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -3449,9 +3501,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" dependencies = [ "value-bag", ] @@ -3482,22 +3534,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "macro_rules_attribute" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" -dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" - [[package]] name = "maplit" version = "1.0.2" @@ -3506,9 +3542,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[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", @@ -3517,9 +3553,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -3557,9 +3593,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3601,6 +3637,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nix" version = "0.26.4" @@ -3643,9 +3685,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -3708,9 +3750,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", @@ -3800,17 +3842,11 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3858,12 +3894,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -3945,6 +3975,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -4036,7 +4077,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -4090,7 +4131,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -4100,7 +4152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -4112,6 +4164,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -4210,15 +4268,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "regex" version = "1.12.3" @@ -4250,9 +4299,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -4279,7 +4328,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier 0.6.2", + "rustls-platform-verifier 0.7.0", "sync_wrapper", "tokio", "tokio-native-tls", @@ -4315,9 +4364,9 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", "thiserror 2.0.18", @@ -4368,9 +4417,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -4382,15 +4431,14 @@ dependencies = [ [[package]] name = "rustls-ffi" -version = "0.15.3" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e80b8f2dee9c7cd5ad441577a8f350cf67080205dd3415bbafa7998fc6b5cf" +checksum = "4128514cb6472050cba340cdac098a235c53e6aad276737ce1d7b24a19260392" dependencies = [ "libc", "log", - "macro_rules_attribute", "rustls", - "rustls-platform-verifier 0.7.0", + "rustls-platform-verifier 0.5.3", "rustls-webpki", ] @@ -4418,11 +4466,11 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.21.1", "log", @@ -4433,8 +4481,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", ] [[package]] @@ -4443,7 +4491,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -4454,7 +4502,7 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs", + "webpki-root-certs 1.0.7", "windows-sys 0.61.2", ] @@ -4534,7 +4582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4588,9 +4636,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4663,7 +4711,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4683,9 +4731,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[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" @@ -4773,9 +4821,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4783,9 +4831,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -4900,6 +4948,27 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -5068,9 +5137,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -5181,20 +5250,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5327,9 +5396,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uluru" @@ -5422,9 +5491,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5500,9 +5569,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5513,9 +5582,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -5523,9 +5592,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5533,9 +5602,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5546,9 +5615,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -5589,9 +5658,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -5607,6 +5676,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.7", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -6016,9 +6094,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "winreg" @@ -6180,18 +6258,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -6200,9 +6278,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 23147e565e1..a76f2571285 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -296,7 +296,8 @@ members = [ "gix-fsck", "tests/tools", "tests/it", - "gix-shallow" + "gix-shallow", + "gix-stash", ] [workspace.dependencies] diff --git a/gix-stash/CHANGELOG.md b/gix-stash/CHANGELOG.md new file mode 100644 index 00000000000..37034477253 --- /dev/null +++ b/gix-stash/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.0.0 (Unreleased) + +The initial release. + +### New Features + +- `list(refs)` walks the `refs/stash` reflog and returns every stash entry + newest-first. Returns an empty `Outcome` when `refs/stash` is unborn, matching + `git stash list` output on a stash-free repo. + +- `push(ctx, head_commit, head_tree, head_branch, options)` captures the current + working tree as a new stash commit at `refs/stash`: + - parent[0] is the commit `HEAD` points at, parent[1] is a commit whose tree + matches the index at stash time, and parent[2] (optional, when + `Options::include_untracked` is set) carries the untracked files. + - The stash commit's own tree reflects the **working-tree** state for tracked + files (not the index) so unstaged modifications are captured. + - After the ref transaction the worktree is reset to `HEAD` via + `gix_worktree_state::checkout`; untracked files captured into parent[2] are + removed from disk. + - Errors: `EmptyRepository`, `NoLocalChanges`, ODB write failures, ref + transaction failures, worktree I/O. + +- `pop(ctx, head_tree, options)` applies the latest stash to the working tree + and drops the entry: + - Performs a 3-way merge via `gix_merge::tree` with base = stash parent[0] + tree, ours = current `head_tree`, theirs = stash WIP tree. + - On a clean merge: writes the merged tree to the worktree via + `gix_worktree_state::checkout`, restores parent[2] untracked files when + present, then drops `refs/stash` (deletes the ref when the stack is + exhausted, otherwise advances it to the next reflog entry). + - On conflict: `Outcome::had_conflicts` is set, conflict markers are written + to the worktree, and `refs/stash` is left untouched (matching + `git stash pop` semantics). + - Errors: `NoStash`, merge failures, ODB I/O, ref transaction failures. + +### Known limitations + +- The default `checkout_options::filters` and `checkout_options::attributes` are + empty. Callers wiring this crate into porcelain (e.g. `gix` at the + `Repository` level) must populate them so smudge/clean filters and + gitattributes run during the worktree write. +- Tracked entries that have been **deleted from the worktree** are stored with + their index OID rather than recorded as a deletion in the WIP tree. A pop of + such a stash will not restore the deletion. +- The index produced by `pop` after a clean merge does not preserve stat data or + timestamps from the merged tree. +- Operations that aren't `push` / `pop` / `list` (`apply`, `drop`, `show`, + `branch`, autostash integration with rebase-like workflows) are deferred. diff --git a/gix-stash/Cargo.toml b/gix-stash/Cargo.toml new file mode 100644 index 00000000000..e2159d8de9b --- /dev/null +++ b/gix-stash/Cargo.toml @@ -0,0 +1,56 @@ +lints.workspace = true + +[package] +name = "gix-stash" +version = "0.0.0" +repository = "https://github.com/GitoxideLabs/gitoxide" +license = "MIT OR Apache-2.0" +description = "A crate of the gitoxide project providing `git stash` plumbing (push + pop)" +authors = ["Sebastian Thiel "] +edition = "2024" +rust-version = "1.85" +include = ["/src/**/*", "/LICENSE-*"] + +[lib] +doctest = false + +[features] +## Enable support for the SHA-1 hash by forwarding the feature to dependencies. +sha1 = ["gix-hash/sha1", "gix-index/sha1", "gix-object/sha1"] + +[dependencies] +gix-hash = { version = "^0.25.0", path = "../gix-hash" } +gix-object = { version = "^0.61.0", path = "../gix-object" } +gix-index = { version = "^0.52.0", path = "../gix-index" } +gix-ref = { version = "^0.64.0", path = "../gix-ref" } +gix-actor = { version = "^0.41.0", path = "../gix-actor" } +gix-date = { version = "^0.15.3", path = "../gix-date" } +gix-features = { version = "^0.48.0", path = "../gix-features", features = ["progress"] } +gix-path = { version = "^0.12.0", path = "../gix-path" } +gix-validate = { version = "^0.11.1", path = "../gix-validate" } +gix-diff = { version = "^0.64.0", path = "../gix-diff", default-features = false, features = ["blob"] } +gix-merge = { version = "^0.17.0", path = "../gix-merge" } +gix-worktree-state = { version = "^0.31.0", path = "../gix-worktree-state" } +gix-lock = { version = "^23.0.0", path = "../gix-lock" } + +bstr = { version = "1.12.0", default-features = false, features = ["std"] } +thiserror = "2.0.18" + +[dev-dependencies] +gix-testtools = { path = "../tests/tools" } +gix-hash = { path = "../gix-hash", features = ["sha1"] } +gix-odb = { path = "../gix-odb", features = ["sha1"] } +gix-ref = { path = "../gix-ref" } +gix-actor = { path = "../gix-actor" } +gix-date = { path = "../gix-date" } +gix-index = { path = "../gix-index", features = ["sha1"] } +gix-object = { path = "../gix-object" } +gix-worktree = { path = "../gix-worktree" } +gix-diff = { path = "../gix-diff", default-features = false, features = ["blob"] } +gix-merge = { path = "../gix-merge" } +gix-filter = { path = "../gix-filter" } +gix-worktree-state = { path = "../gix-worktree-state" } +bstr = { version = "1.12.0", default-features = false, features = ["std"] } + +[package.metadata.docs.rs] +features = ["sha1"] diff --git a/gix-stash/src/lib.rs b/gix-stash/src/lib.rs new file mode 100644 index 00000000000..312663e3481 --- /dev/null +++ b/gix-stash/src/lib.rs @@ -0,0 +1,45 @@ +//! Plumbing for [`git stash`](https://git-scm.com/docs/git-stash) workflows. +//! +//! This crate implements the `push` and `pop` operations as a starting MVP. +//! Additional operations (`apply`, `drop`, `list`, `show`, `branch`, +//! `autostash`) are tracked in [`crate-status.md`] and may follow. +//! +//! [`crate-status.md`]: https://github.com/GitoxideLabs/gitoxide/blob/main/crate-status.md +//! +//! # Stash representation +//! +//! A stash entry is a merge commit with 2 or 3 parents stored at the single +//! ref `refs/stash`, with the reflog providing the stack of older entries: +//! +//! * `parent[0]` — the commit that `HEAD` pointed at when the stash was made +//! * `parent[1]` — a commit whose tree is the **index** at stash time +//! * `parent[2]` — *(optional, only when `--include-untracked` is used)* — a +//! commit whose tree contains the **untracked** files at stash time +//! +//! The stash commit's own tree is the **working tree** at stash time. +//! +//! # API +//! +//! * [`push()`] — capture working tree (+ index, + optional untracked) and +//! reset to `HEAD`. +//! * [`pop()`] — apply the latest stash to the working tree (3-way merge) and +//! drop it from `refs/stash`. +//! * [`list()`] — walk the `refs/stash` reflog and return every stash entry. +//! +//! All three operate on plumbing handles (index, ODB, ref store, worktree +//! path) rather than a high-level repository — the porcelain layer in `gix` +//! wraps them and provides `Repository::stash_push` / `Repository::stash_pop` +//! / `Repository::stash_list`. + +#![deny(missing_docs, rust_2018_idioms)] +#![forbid(unsafe_code)] + +pub mod list; +pub mod pop; +pub mod push; + +pub use list::{Entry as ListEntry, Outcome as ListOutcome, function::list}; +pub use pop::{Context as PopContext, Error as PopError, Outcome as PopOutcome, function::pop}; +pub use push::{ + Context as PushContext, Error as PushError, Options as PushOptions, Outcome as PushOutcome, function::push, +}; diff --git a/gix-stash/src/list/mod.rs b/gix-stash/src/list/mod.rs new file mode 100644 index 00000000000..34f97fab4ce --- /dev/null +++ b/gix-stash/src/list/mod.rs @@ -0,0 +1,105 @@ +//! Implementation of [`stash list`](https://git-scm.com/docs/git-stash#Documentation/git-stash.txt-list). + +use bstr::BString; +use gix_hash::ObjectId; + +/// A single stash entry as found in the `refs/stash` reflog. +/// +/// The newest entry is index `0` (the current value of `refs/stash`); older +/// entries follow in reverse-chronological order, matching what +/// `git stash list` prints (`stash@{0}`, `stash@{1}`, …). +#[derive(Debug, Clone)] +pub struct Entry { + /// Stack position — `0` is the newest. + pub index: usize, + + /// The stash commit's object id. + pub commit: ObjectId, + + /// The reflog message (e.g. `WIP on main: abc1234 commit subject`). + pub message: BString, + + /// Seconds since the Unix epoch, from the reflog committer line. + pub time_seconds: u64, +} + +/// Result of [`function::list`]. +#[derive(Debug, Clone, Default)] +pub struct Outcome { + /// The stash entries, newest first. Empty when `refs/stash` is unborn + /// (no stashes have ever been created in this repo). + pub entries: Vec, +} + +/// Errors returned by [`function::list`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The reflog file for `refs/stash` could not be read or seeked. + /// + /// This is extracted from [`gix_ref::file::log::Error`] since I/O is + /// the only variant that can occur when a hard-coded, valid ref name is used. + #[error("failed to perform I/O while reading the refs/stash reflog")] + Io(#[from] std::io::Error), + /// An individual reflog line failed to decode. + #[error("failed to decode a reflog line for refs/stash")] + DecodeReflog(#[from] gix_ref::file::log::iter::reverse::Error), +} + +pub(crate) mod function { + use gix_ref::FullName; + + use super::{Entry, Error, Outcome}; + + /// Walk the reflog of `refs/stash` and return every stash entry, newest + /// first. + /// + /// Returns an empty [`Outcome`] when `refs/stash` is unborn — matching + /// `git stash list` which prints nothing in that case. + /// + /// `refs` is the file-based ref store for the repository (typically the + /// `.git/` directory or the common-dir for linked worktrees). + pub fn list(refs: &gix_ref::file::Store) -> Result { + // Parse the well-known ref name once. FullName validates at + // creation time so the expect is safe for this hard-coded literal. + let stash_name: FullName = "refs/stash".try_into().expect("refs/stash is a valid ref name"); + + // 4 KiB sliding window for the reverse-reflog iterator. + let mut buf = vec![0u8; 4 * 1024]; + + let iter = match refs + .reflog_iter_rev(stash_name.as_ref(), &mut buf) + .map_err(|e| match e { + gix_ref::file::log::Error::Io(io) => Error::Io(io), + // Cannot happen: we pass a hard-coded valid ref name. + gix_ref::file::log::Error::RefnameValidation(_) => { + unreachable!("refs/stash is always a valid ref name") + } + })? { + // refs/stash has never been written — no stash entries exist. + None => return Ok(Outcome::default()), + Some(iter) => iter, + }; + + let mut entries: Vec = Vec::new(); + + for line_result in iter { + let line = line_result?; + + let commit = line.new_oid; + let message = line.message.clone(); + // `gix_actor::Signature` carries a parsed `gix_date::Time` field + // whose `seconds` is `i64`; stash entries cannot predate the epoch. + let time_seconds = line.signature.time.seconds.max(0) as u64; + + entries.push(Entry { + // entries are read newest-first, so index 0 = newest. + index: entries.len(), + commit, + message, + time_seconds, + }); + } + + Ok(Outcome { entries }) + } +} diff --git a/gix-stash/src/pop/mod.rs b/gix-stash/src/pop/mod.rs new file mode 100644 index 00000000000..1b691a7a996 --- /dev/null +++ b/gix-stash/src/pop/mod.rs @@ -0,0 +1,481 @@ +//! Implementation of [`stash pop`](https://git-scm.com/docs/git-stash#Documentation/git-stash.txt-pop). + +use gix_hash::ObjectId; + +/// Result of a successful [`function::pop`]. +#[derive(Debug, Clone)] +pub struct Outcome { + /// The id of the stash commit that was applied + dropped. + pub applied: ObjectId, + + /// The new value of `refs/stash` after dropping the applied entry — `None` + /// when no older stash entries remain. + pub new_top: Option, + + /// Whether the apply step produced merge conflicts. + /// + /// When `true` the merged result (including conflict markers) has been + /// written to the working tree, but `refs/stash` has **not** been dropped + /// so that the stash entry can be re-applied after manual resolution; + /// [`Outcome::had_conflicts`] is set to `true`. + pub had_conflicts: bool, +} + +/// Errors returned by [`function::pop`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// `refs/stash` is unborn — no stash entries exist. + #[error("no stash entries to pop (refs/stash is unborn)")] + NoStash, + + /// Looking up `refs/stash` in the ref store failed. + #[error("failed to read refs/stash from the ref store")] + FindRef(#[from] gix_ref::file::find::Error), + + /// An object could not be found in the database. + #[error("required object was not found in the object database")] + FindObject(#[from] gix_object::find::existing_object::Error), + + /// The reflog for `refs/stash` could not be read. + #[error("failed to read the refs/stash reflog")] + Io(#[from] std::io::Error), + + /// A reflog line for `refs/stash` failed to decode. + #[error("failed to decode a reflog line for refs/stash")] + DecodeReflog(#[from] gix_ref::file::log::iter::reverse::Error), + + /// Preparing the ref transaction failed. + #[error("failed to prepare the refs/stash ref transaction")] + PrepareTransaction(#[from] gix_ref::file::transaction::prepare::Error), + + /// Committing the ref transaction failed. + #[error("failed to commit the refs/stash ref transaction")] + CommitTransaction(#[from] gix_ref::file::transaction::commit::Error), + + /// The 3-way tree merge failed. + #[error("failed to merge stash tree into the working tree")] + Merge(#[from] gix_merge::tree::Error), + + /// Writing the merged tree to the object database failed. + #[error("failed to write merged tree to the object database")] + WriteTree(#[source] Box), + + /// Constructing the merge-result index for the worktree checkout failed. + #[error("failed to construct index from merged tree")] + IndexFromTree(#[from] gix_index::init::from_tree::Error), + + /// Writing the merge result to the working tree failed. + #[error("failed to write merge result to the working tree")] + Checkout(#[from] gix_worktree_state::checkout::Error), + + /// Reading a blob to restore an untracked file failed. + #[error("failed to read untracked blob for restore at {path:?}")] + RestoreUntracked { + /// The path where the untracked file was to be written. + path: std::path::PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, +} + +/// Repository-level plumbing handles required by [`function::pop`]. +/// +/// `Objects` must implement [`gix_object::Find`], [`gix_object::FindHeader`], +/// and [`gix_object::Write`] — all of which are satisfied by the typical +/// `gix_odb::Handle` / `gix::Repository` object store. +pub struct Context<'a, Objects> { + /// The file-based ref store for the repository. + pub refs: &'a gix_ref::file::Store, + /// A combined readable + writable ODB handle. + pub objects: &'a Objects, + /// Identity and timestamp to use for the ref-transaction committer line. + pub committer: gix_actor::SignatureRef<'a>, + /// Absolute path to the working-tree root. + pub worktree: &'a std::path::Path, + /// Pre-configured blob merge platform for 3-way content merges. + pub blob_merge: &'a mut gix_merge::blob::Platform, + /// Pre-configured diff resource cache for rename tracking during tree merge. + pub diff_cache: &'a mut gix_diff::blob::Platform, + /// Options controlling the worktree checkout after a successful merge. + pub checkout_options: gix_worktree_state::checkout::Options, +} + +pub(crate) mod function { + use std::path::Path; + + use bstr::ByteSlice; + use gix_hash::ObjectId; + use gix_object::FindExt; + use gix_ref::{ + FullName, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }; + + use super::{Context, Error, Outcome}; + + /// Apply the latest stash entry to the working tree and drop it from + /// `refs/stash`. + /// + /// # Parameters + /// + /// * `head_tree` — OID of the root tree of the current `HEAD` commit. + /// This is the "ours" side of the 3-way merge. + /// + /// # Merge semantics + /// + /// Performs a 3-way merge of: + /// * **base** — the tree of `parent[0]` of the stash commit (HEAD at stash + /// time) + /// * **ours** — `head_tree` (current HEAD tree) + /// * **theirs** — the stash commit's own tree (working-tree state at stash + /// time) + /// + /// If the merge is clean the result is checked out into the working tree + /// and `refs/stash` is dropped. On conflict, the working tree receives + /// conflict markers and `refs/stash` is left in place so the entry can be + /// re-applied after manual resolution; [`Outcome::had_conflicts`] is set + /// to `true` in that case. + /// + /// If the stash commit has a third parent (`parent[2]`), its tree is + /// treated as the untracked-files snapshot and those files are restored to + /// the working tree after a clean merge. + /// + /// # Untracked-restore conflicts + /// + /// Before writing any file from `parent[2]`, all target paths are scanned + /// for pre-existing entries. If any would be clobbered, [`Outcome::had_conflicts`] + /// is set to `true` and `refs/stash` is **not** dropped — no files from + /// `parent[2]` are written in this case, preventing data loss. + pub fn pop(ctx: Context<'_, Objects>, head_tree: ObjectId) -> Result + where + Objects: gix_object::Find + gix_object::FindHeader + gix_object::Write + Send + Clone, + { + let Context { + refs, + objects, + committer, + worktree, + blob_merge, + diff_cache, + checkout_options, + } = ctx; + + let stash_ref: FullName = "refs/stash".try_into().expect("refs/stash is a valid ref name"); + + // ------------------------------------------------------------------ // + // Read the current tip of refs/stash. + // ------------------------------------------------------------------ // + let stash_oid = refs + .try_find(stash_ref.as_ref())? + .ok_or(Error::NoStash)? + .target + .try_id() + .map(ToOwned::to_owned) + .ok_or(Error::NoStash)?; + + // ------------------------------------------------------------------ // + // Decode the stash commit. + // ------------------------------------------------------------------ // + let mut commit_buf = Vec::new(); + let stash_commit = objects.find_commit(&stash_oid, &mut commit_buf)?; + + // The stash commit's own tree is the WIP working-tree state. + let stash_tree = stash_commit.tree(); + + // parent[0] is the original HEAD at stash time — the merge base. + let base_commit = stash_commit.parents().next().ok_or(Error::NoStash)?; + + // parent[2] (optional) is the untracked-files commit. + let untracked_commit: Option = stash_commit.parents().nth(2); + + drop(stash_commit); + + // Resolve the base tree from parent[0]. + let mut base_buf = Vec::new(); + let base_commit_obj = objects.find_commit(&base_commit, &mut base_buf)?; + let base_tree = base_commit_obj.tree(); + drop(base_commit_obj); + + // ------------------------------------------------------------------ // + // 3-way tree merge: + // base = HEAD tree at stash time (stash parent[0]'s tree) + // ours = current HEAD tree + // theirs = stash WIP tree + // ------------------------------------------------------------------ // + let mut diff_state = gix_diff::tree::State::default(); + let labels = gix_merge::blob::builtin_driver::text::Labels::default(); + + let merge_outcome = gix_merge::tree( + &base_tree, + &head_tree, + &stash_tree, + labels, + objects, + |buf: &[u8]| objects.write_buf(gix_object::Kind::Blob, buf), + &mut diff_state, + diff_cache, + blob_merge, + gix_merge::tree::Options::default(), + )?; + + let mut had_conflicts = merge_outcome.has_unresolved_conflicts(gix_merge::tree::TreatAsUnresolved::git()); + + // Write the merged tree to the ODB. + // `tree` is an Editor; we write it by consuming it via the write() method. + let mut merge_tree_editor = merge_outcome.tree; + let merged_tree_oid = merge_tree_editor.write(|tree| objects.write(tree).map_err(Error::WriteTree))?; + + // ------------------------------------------------------------------ // + // Checkout merged tree into the working tree. + // ------------------------------------------------------------------ // + let mut merged_index = gix_index::State::from_tree( + &merged_tree_oid, + objects, + gix_validate::path::component::Options::default(), + )?; + let should_interrupt = std::sync::atomic::AtomicBool::new(false); + gix_worktree_state::checkout( + &mut merged_index, + worktree, + objects.clone(), + &gix_features::progress::Discard, + &gix_features::progress::Discard, + &should_interrupt, + checkout_options, + )?; + + // ------------------------------------------------------------------ // + // Restore untracked files if the stash had a parent[2] and merge clean. + // ------------------------------------------------------------------ // + // First check whether any target path already exists on disk; if so, + // treat it as a conflict to avoid silent data loss. + if !had_conflicts { + if let Some(untracked_commit_oid) = untracked_commit { + let mut uc_buf = Vec::new(); + let uc_commit = objects.find_commit(&untracked_commit_oid, &mut uc_buf)?; + let untracked_tree_oid = uc_commit.tree(); + drop(uc_commit); + if collect_restore_targets(&untracked_tree_oid, worktree, objects)? { + // At least one target path already exists on disk — treat + // this as a conflict. Leave refs/stash intact and report. + had_conflicts = true; + } else { + restore_tree_to_worktree(&untracked_tree_oid, worktree, objects)?; + } + } + } + + // ------------------------------------------------------------------ // + // Look up the second-newest entry so we know what to set refs/stash to + // after dropping the top. + // ------------------------------------------------------------------ // + let mut reflog_buf = vec![0u8; 4 * 1024]; + let new_top: Option = { + let mut iter = refs + .reflog_iter_rev(stash_ref.as_ref(), &mut reflog_buf) + .map_err(|e| match e { + gix_ref::file::log::Error::Io(io) => Error::Io(io), + gix_ref::file::log::Error::RefnameValidation(_) => { + unreachable!("refs/stash is always a valid ref name") + } + })? + .ok_or(Error::NoStash)?; + + // Index 0 = current tip (what we are popping); index 1 = the one before. + iter.nth(1).transpose()?.map(|line| line.new_oid) + }; + + // ------------------------------------------------------------------ // + // Drop or update refs/stash — only if the merge was clean. + // ------------------------------------------------------------------ // + if !had_conflicts { + let edit = if let Some(next_oid) = new_top { + RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: true, + message: "drop stash".into(), + }, + expected: PreviousValue::MustExistAndMatch(gix_ref::Target::Object(stash_oid)), + new: gix_ref::Target::Object(next_oid), + }, + name: stash_ref, + deref: false, + } + } else { + RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(gix_ref::Target::Object(stash_oid)), + log: RefLog::AndReference, + }, + name: stash_ref, + deref: false, + } + }; + + let committer_owned: gix_actor::Signature = committer.into(); + let mut time_buf = gix_date::parse::TimeBuf::default(); + refs.transaction() + .prepare( + std::iter::once(edit), + gix_lock::acquire::Fail::Immediately, + gix_lock::acquire::Fail::Immediately, + )? + .commit(committer_owned.to_ref(&mut time_buf))?; + } + + Ok(Outcome { + applied: stash_oid, + new_top, + had_conflicts, + }) + } + + /// Walk `tree_oid` recursively and write every blob to its corresponding + /// path under `dir`. Used to restore untracked files from `parent[2]`. + fn restore_tree_to_worktree( + tree_oid: &gix_hash::oid, + dir: &Path, + find: &impl gix_object::FindExt, + ) -> Result<(), super::Error> { + let mut buf = Vec::new(); + restore_tree_recursive(tree_oid, dir, find, &mut buf) + } + + fn restore_tree_recursive( + tree_oid: &gix_hash::oid, + dir: &Path, + find: &impl gix_object::FindExt, + buf: &mut Vec, + ) -> Result<(), super::Error> { + use gix_object::tree::EntryKind; + + buf.clear(); + let tree = find.find_tree(tree_oid, buf)?.to_owned(); + + for entry in tree.entries { + let name_bytes: &bstr::BStr = entry.filename.as_ref(); + let entry_path = dir.join(gix_path::from_bstr(name_bytes)); + + match entry.mode.kind() { + EntryKind::Tree => { + std::fs::create_dir_all(&entry_path).map_err(|e| super::Error::RestoreUntracked { + path: entry_path.clone(), + source: e, + })?; + let mut sub_buf = Vec::new(); + restore_tree_recursive(&entry.oid, &entry_path, find, &mut sub_buf)?; + } + EntryKind::Blob | EntryKind::BlobExecutable => { + let mut blob_buf = Vec::new(); + let blob = find.find_blob(&entry.oid, &mut blob_buf)?; + if let Some(parent) = entry_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| super::Error::RestoreUntracked { + path: entry_path.clone(), + source: e, + })?; + } + std::fs::write(&entry_path, blob.data).map_err(|e| super::Error::RestoreUntracked { + path: entry_path, + source: e, + })?; + } + EntryKind::Link => { + let mut blob_buf = Vec::new(); + let blob = find.find_blob(&entry.oid, &mut blob_buf)?; + let target = gix_path::from_bstr(blob.data.as_bstr()); + if let Some(parent) = entry_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| super::Error::RestoreUntracked { + path: entry_path.clone(), + source: e, + })?; + } + // Remove any existing entry before creating the symlink. + let _ = std::fs::remove_file(&entry_path); + + #[cfg(unix)] + let symlink_result = std::os::unix::fs::symlink(&target, &entry_path); + + // Windows symlinks need a kind hint (file vs directory) and + // typically require admin or Developer Mode. We try the file + // variant first; callers that need full symlink semantics on + // Windows can resolve manually using the preserved + // `refs/stash`. The pre-flight scan in + // `collect_restore_targets_recursive` already short-circuits + // before any writes when a target exists, so a partial + // restore here only happens when the file system rejects the + // call (e.g. insufficient privileges). + #[cfg(windows)] + let symlink_result = std::os::windows::fs::symlink_file(&target, &entry_path); + + #[cfg(not(any(unix, windows)))] + let symlink_result = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "symlink creation is not supported on this platform", + )); + + symlink_result.map_err(|e| super::Error::RestoreUntracked { + path: entry_path, + source: e, + })?; + } + EntryKind::Commit => { + // Submodule — skip. + } + } + } + Ok(()) + } + + /// Walk `tree_oid` recursively and return `true` if any leaf file path + /// already exists on disk under `dir`. + /// + /// Used as a pre-flight check before [`restore_tree_to_worktree`] to avoid + /// silently clobbering files the user created after stashing. + fn collect_restore_targets( + tree_oid: &gix_hash::oid, + dir: &Path, + find: &impl gix_object::FindExt, + ) -> Result { + let mut buf = Vec::new(); + collect_restore_targets_recursive(tree_oid, dir, find, &mut buf) + } + + fn collect_restore_targets_recursive( + tree_oid: &gix_hash::oid, + dir: &Path, + find: &impl gix_object::FindExt, + buf: &mut Vec, + ) -> Result { + use gix_object::tree::EntryKind; + + buf.clear(); + let tree = find.find_tree(tree_oid, buf)?.to_owned(); + + for entry in tree.entries { + let name_bytes: &bstr::BStr = entry.filename.as_ref(); + let entry_path = dir.join(gix_path::from_bstr(name_bytes)); + + match entry.mode.kind() { + EntryKind::Tree => { + let mut sub_buf = Vec::new(); + if collect_restore_targets_recursive(&entry.oid, &entry_path, find, &mut sub_buf)? { + return Ok(true); + } + } + EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => { + if entry_path.try_exists().map_err(|e| super::Error::RestoreUntracked { + path: entry_path.clone(), + source: e, + })? { + return Ok(true); + } + } + EntryKind::Commit => {} + } + } + Ok(false) + } +} diff --git a/gix-stash/src/push/mod.rs b/gix-stash/src/push/mod.rs new file mode 100644 index 00000000000..8d9521b5eb3 --- /dev/null +++ b/gix-stash/src/push/mod.rs @@ -0,0 +1,669 @@ +//! Implementation of [`stash push`](https://git-scm.com/docs/git-stash#Documentation/git-stash.txt-push). + +use bstr::BString; +use gix_hash::ObjectId; + +/// Options controlling [`function::push`]. +#[derive(Debug, Clone, Default)] +pub struct Options { + /// Include untracked (but not git-ignored) files in `parent[2]` of the + /// stash commit and remove them from the working tree. + /// + /// Note that `.gitignore` rules are **not** consulted in the current + /// implementation — all untracked files are included. A future + /// implementation will wire up `gix-worktree`'s exclude stack to provide + /// full `.gitignore` support. + /// + /// TODO(gix-stash): respect .gitignore via `gix-worktree` exclude stack. + pub include_untracked: bool, + + /// Also include ignored files when `include_untracked` is set. Has no + /// effect on its own. + /// + /// Not yet implemented; included for API completeness. + pub include_ignored: bool, + + /// Keep the index state intact in the working tree after stashing — the + /// stash still captures it, but the on-disk working tree continues to + /// reflect what was staged. + pub keep_index: bool, + + /// Optional explicit message — written to the stash commit subject and + /// the reflog entry. When `None`, the message defaults to + /// `WIP on : `. + pub message: Option, +} + +/// Result of a successful [`function::push`]. +#[derive(Debug, Clone)] +pub struct Outcome { + /// The id of the newly-created stash commit (now `refs/stash`). + pub stash: ObjectId, + + /// The id of the index-state commit (`parent[1]` of the stash commit). + pub index_commit: ObjectId, + + /// The id of the untracked-files commit (`parent[2]`), if one was created. + pub untracked_commit: Option, + + /// The previous value of `refs/stash`, now reachable only via reflog. + pub previous: Option, +} + +/// Errors returned by [`function::push`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The repository has no commits yet — stash requires at least one. + #[error("cannot stash in an empty repository (HEAD has no commits)")] + EmptyRepository, + + /// There are no local changes to stash. + #[error("no local changes to save")] + NoLocalChanges, + + /// An index entry's mode could not be converted to a tree entry mode. + #[error("index entry at path {path:?} has an unrecognised file mode ({mode:#o})")] + InvalidIndexEntryMode { + /// Repository-relative path of the offending entry. + path: BString, + /// The raw mode bits that could not be mapped. + mode: u32, + }, + + /// A tree could not be written to the object database. + #[error("failed to write tree object to the object database")] + WriteTree(#[source] Box), + + /// A blob could not be written to the object database. + #[error("failed to write blob object to the object database")] + WriteBlob(#[source] Box), + + /// A commit could not be written to the object database. + #[error("failed to write commit object to the object database")] + WriteCommit(#[source] Box), + + /// An object could not be found in the database. + #[error("required object was not found in the object database")] + FindObject(#[from] gix_object::find::existing_object::Error), + + /// Reading a worktree file failed while building the WIP tree or untracked-files tree. + #[error("failed to read worktree file at {path:?}")] + ReadFile { + /// The path that failed. + path: std::path::PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// Walking the worktree for untracked files failed. + #[error("failed to walk the worktree directory")] + WalkWorktree(#[source] std::io::Error), + + /// The tree editor encountered a problem assembling a tree. + #[error("failed to assemble tree from index or worktree entries")] + TreeEditor(#[from] gix_object::tree::editor::Error), + + /// Preparing the ref transaction failed. + #[error("failed to prepare the refs/stash ref transaction")] + PrepareTransaction(#[from] gix_ref::file::transaction::prepare::Error), + + /// Committing the ref transaction failed. + #[error("failed to commit the refs/stash ref transaction")] + CommitTransaction(#[from] gix_ref::file::transaction::commit::Error), + + /// Constructing the HEAD index for the worktree reset failed. + #[error("failed to construct HEAD index for worktree reset")] + IndexFromTree(#[from] gix_index::init::from_tree::Error), + + /// Resetting the working tree to HEAD after stashing failed. + #[error("failed to reset working tree to HEAD after stashing")] + Checkout(#[from] gix_worktree_state::checkout::Error), +} + +/// Repository-level plumbing handles required by [`function::push`]. +/// +/// Grouping these together avoids crossing the "too many arguments" threshold +/// that clippy enforces. +/// +/// `Objects` must implement [`gix_object::Find`], [`gix_object::FindHeader`], +/// and [`gix_object::Write`] — all of which are satisfied by the typical +/// `gix_odb::Handle` / `gix::Repository` object store. +pub struct Context<'a, Objects> { + /// The file-based ref store for the repository. + pub refs: &'a gix_ref::file::Store, + /// A combined readable + writable ODB handle. + pub objects: &'a Objects, + /// The current in-memory index state. + pub index: &'a gix_index::State, + /// Absolute path to the working-tree root. + pub worktree: &'a std::path::Path, + /// Identity and timestamp to use for all created commits. + pub committer: gix_actor::SignatureRef<'a>, + /// Options controlling the worktree-reset checkout that runs after the + /// stash commit is recorded. The caller is responsible for populating + /// `attributes` (`.gitattributes` from the index) and `filters` + /// (a fully-configured `gix_filter::Pipeline`). The remaining fields + /// can be left at their defaults for a typical stash. + pub checkout_options: gix_worktree_state::checkout::Options, +} + +pub(crate) mod function { + use std::path::Path; + + use bstr::{BString, ByteSlice}; + use gix_hash::ObjectId; + use gix_object::{Tree, tree::EntryKind}; + use gix_ref::{ + FullName, + transaction::{Change, LogChange, PreviousValue, RefEdit}, + }; + + use super::{Context, Error, Options, Outcome}; + + /// Capture the current working tree (+ index, + optional untracked files) + /// as a new stash commit at `refs/stash`. + /// + /// All plumbing handles are passed via [`Context`]. The remaining + /// parameters are: + /// + /// * `head_commit` — OID of the commit `HEAD` currently points at. + /// * `head_tree` — OID of the root tree of `head_commit`. + /// * `head_branch` — full name of the current branch (e.g. + /// `refs/heads/main`), or `None` when `HEAD` is detached. + /// * `options` — behavioural flags. + /// + /// # Limitations + /// + /// * `.gitignore` rules are **not consulted** when `include_untracked` is + /// set — all non-tracked, non-`.git` files are included. + /// + /// TODO(gix-stash): wire up `gix-worktree` exclude stack. + pub fn push( + ctx: Context<'_, Objects>, + head_commit: ObjectId, + head_tree: ObjectId, + head_branch: Option<&gix_ref::FullNameRef>, + options: Options, + ) -> Result + where + Objects: gix_object::Find + gix_object::FindHeader + gix_object::Write + Send + Clone, + { + let Context { + refs, + objects, + index, + worktree, + committer, + checkout_options, + } = ctx; + // ------------------------------------------------------------------ // + // Build all three trees before writing any commits so the + // NoLocalChanges check can see the full picture. + // ------------------------------------------------------------------ // + let wip_tree_oid = write_wip_tree(index, objects, objects, head_tree, worktree)?; + let index_tree_oid = write_tree_from_index(index, objects, objects, head_tree)?; + + // Collect untracked files (trees only, no commits yet) so we can + // include them in the NoLocalChanges decision below. + let (pending_untracked_tree, pending_untracked_paths) = if options.include_untracked { + let (tree, paths) = write_untracked_tree(objects, objects, worktree, index)?; + (Some(tree), paths) + } else { + (None, Vec::new()) + }; + + // Guard: nothing to stash when all three trees are empty / identical + // to HEAD. Checking the untracked tree against the empty-tree OID + // guards against the case where `include_untracked=true` but the + // worktree has no untracked files. + let has_wt_changes = wip_tree_oid != head_tree; + let has_index_changes = index_tree_oid != head_tree; + let empty_tree = ObjectId::empty_tree(head_commit.kind()); + let has_untracked = pending_untracked_tree.as_ref().is_some_and(|t| *t != empty_tree); + if !has_wt_changes && !has_index_changes && !has_untracked { + return Err(Error::NoLocalChanges); + } + + // ------------------------------------------------------------------ // + // Build common text fragments used in commit messages. + // ------------------------------------------------------------------ // + let head_subject = first_line_of_commit_message(objects, head_commit)?; + let short_hash = short_id(head_commit); + let branch_name: BString = head_branch.map_or_else(|| BString::from("HEAD"), |n| n.shorten().to_owned()); + + let index_msg = format!( + "index on {branch}: {short} {subj}", + branch = branch_name.as_bstr(), + short = short_hash.as_bstr(), + subj = head_subject.as_bstr(), + ); + let index_commit_oid = write_commit( + objects, + index_tree_oid, + &[head_commit], + committer, + index_msg.as_bytes().as_bstr(), + )?; + + // ------------------------------------------------------------------ // + // parent[2] — untracked files commit (optional). + // ------------------------------------------------------------------ // + let (untracked_commit_oid, untracked_paths) = if let Some(untracked_tree) = pending_untracked_tree { + if untracked_tree != empty_tree { + let msg = format!( + "untracked files on {branch}: {short} {subj}", + branch = branch_name.as_bstr(), + short = short_hash.as_bstr(), + subj = head_subject.as_bstr(), + ); + ( + Some(write_commit( + objects, + untracked_tree, + &[], + committer, + msg.as_bytes().as_bstr(), + )?), + pending_untracked_paths, + ) + } else { + (None, Vec::new()) + } + } else { + (None, Vec::new()) + }; + + // ------------------------------------------------------------------ // + // Stash commit — WIP tree captures the *actual* working-tree state. + // ------------------------------------------------------------------ // + let stash_msg: BString = options.message.clone().unwrap_or_else(|| { + format!( + "WIP on {branch}: {short} {subj}", + branch = branch_name.as_bstr(), + short = short_hash.as_bstr(), + subj = head_subject.as_bstr(), + ) + .into() + }); + + let mut stash_parents: Vec = vec![head_commit, index_commit_oid]; + if let Some(u) = untracked_commit_oid { + stash_parents.push(u); + } + let stash_oid = write_commit(objects, wip_tree_oid, &stash_parents, committer, stash_msg.as_bstr())?; + + // ------------------------------------------------------------------ // + // Update refs/stash via transaction. + // ------------------------------------------------------------------ // + let stash_ref_name: FullName = "refs/stash".try_into().expect("refs/stash is a valid ref name"); + + let previous = refs + .try_find(stash_ref_name.as_ref()) + .ok() + .flatten() + .and_then(|r| r.target.try_id().map(ToOwned::to_owned)); + + let expected = match &previous { + Some(prev_oid) => PreviousValue::ExistingMustMatch(gix_ref::Target::Object(*prev_oid)), + None => PreviousValue::Any, + }; + + let edit = RefEdit { + change: Change::Update { + log: LogChange { + mode: gix_ref::transaction::RefLog::AndReference, + force_create_reflog: true, + message: stash_msg.clone(), + }, + expected, + new: gix_ref::Target::Object(stash_oid), + }, + name: stash_ref_name, + deref: false, + }; + + let committer_owned: gix_actor::Signature = committer.into(); + let mut time_buf = gix_date::parse::TimeBuf::default(); + refs.transaction() + .prepare( + std::iter::once(edit), + gix_lock::acquire::Fail::Immediately, + gix_lock::acquire::Fail::Immediately, + )? + .commit(committer_owned.to_ref(&mut time_buf))?; + + // ------------------------------------------------------------------ // + // Reset working tree — to HEAD, or to the index when keep_index=true. + // ------------------------------------------------------------------ // + // With keep_index=true the WT is reset to the *index* state (staged + // changes are preserved on disk) rather than to HEAD. We already + // computed `index_tree_oid` above, so we just reuse it. + let reset_tree = if options.keep_index { index_tree_oid } else { head_tree }; + let mut reset_index = + gix_index::State::from_tree(&reset_tree, objects, gix_validate::path::component::Options::default())?; + let should_interrupt = std::sync::atomic::AtomicBool::new(false); + gix_worktree_state::checkout( + &mut reset_index, + worktree, + objects.clone(), + &gix_features::progress::Discard, + &gix_features::progress::Discard, + &should_interrupt, + checkout_options, + )?; + + // ------------------------------------------------------------------ // + // Remove untracked files that were captured in parent[2]. + // ------------------------------------------------------------------ // + for abs_path in &untracked_paths { + // Best-effort: ignore errors (file may have already been removed). + let _ = std::fs::remove_file(abs_path); + } + + Ok(Outcome { + stash: stash_oid, + index_commit: index_commit_oid, + untracked_commit: untracked_commit_oid, + previous, + }) + } + + // ======================================================================= // + // Private helpers + // ======================================================================= // + + /// Build a WIP tree that captures the **actual working-tree state** for + /// every tracked entry, not just the index content. + /// + /// For each index entry: + /// * Regular files and executables: read the file from disk, hash it as a + /// blob, and use the resulting OID in the tree. This captures unstaged + /// modifications. If the WT file is missing (a `git rm`-style change), + /// the index OID is reused — the file is still represented in the stash. + /// * Symlinks: read the link target from disk and store it as a blob. + /// * Submodules (`Commit` mode): reuse the index OID without recursing. + /// + /// The resulting tree therefore reflects the state an observer would see + /// by reading every file from the worktree. + fn write_wip_tree( + index: &gix_index::State, + find: &impl gix_object::FindExt, + odb: &impl gix_object::Write, + head_tree: ObjectId, + worktree: &Path, + ) -> Result { + let object_hash = index.object_hash(); + + // Seed the editor with HEAD's root tree so existing sub-tree objects + // can be reused without being re-fetched. + let mut buf = Vec::new(); + let root_tree = find.find_tree(&head_tree, &mut buf)?.to_owned(); + let mut editor = gix_object::tree::Editor::new(root_tree, find, object_hash); + + let paths = index.path_backing(); + for entry in index.entries() { + // Skip sparse-checkout directory markers. + if entry.mode.is_sparse() { + continue; + } + let path = entry.path_in(paths); + let entry_kind = entry + .mode + .to_tree_entry_mode() + .ok_or_else(|| Error::InvalidIndexEntryMode { + path: path.to_owned(), + mode: entry.mode.bits(), + })? + .kind(); + + let components: Vec<&bstr::BStr> = path.split(|b| *b == b'/').map(bstr::ByteSlice::as_bstr).collect(); + + let blob_oid = match entry_kind { + EntryKind::Blob | EntryKind::BlobExecutable => { + // Read the actual working-tree file so that unstaged + // modifications are captured. + let abs_path = worktree.join(gix_path::from_bstr(path).as_ref()); + match std::fs::read(&abs_path) { + Ok(content) => odb + .write_buf(gix_object::Kind::Blob, &content) + .map_err(Error::WriteBlob)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // File deleted from WT but index still tracks it. + // Keep the index OID so the stash still records + // the last known content. + // TODO(gix-stash): represent deleted-from-WT files + // as a deletion in the WIP tree so pop can replay them. + entry.id + } + Err(e) => { + return Err(Error::ReadFile { + path: abs_path, + source: e, + }); + } + } + } + EntryKind::Link => { + // Symlinks are stored as blobs containing the link target. + let abs_path = worktree.join(gix_path::from_bstr(path).as_ref()); + match std::fs::read_link(&abs_path) { + Ok(target) => { + let target_bytes = gix_path::into_bstr(target); + odb.write_buf(gix_object::Kind::Blob, target_bytes.as_ref()) + .map_err(Error::WriteBlob)? + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => entry.id, + Err(e) => { + return Err(Error::ReadFile { + path: abs_path, + source: e, + }); + } + } + } + EntryKind::Commit => { + // Submodule — record the checked-out commit OID as-is. + entry.id + } + EntryKind::Tree => { + // Should not appear in index entries, but be defensive. + entry.id + } + }; + + editor.upsert(components, entry_kind, blob_oid)?; + } + + editor.write(|tree| odb.write(tree).map_err(Error::WriteTree)) + } + + /// Build a tree mirroring the current index state and write it to the ODB. + fn write_tree_from_index( + index: &gix_index::State, + find: &impl gix_object::FindExt, + odb: &impl gix_object::Write, + head_tree: ObjectId, + ) -> Result { + let object_hash = index.object_hash(); + + // Seed the editor with HEAD's root tree so existing sub-tree objects + // can be reused without being re-fetched. + let mut buf = Vec::new(); + let root_tree = find.find_tree(&head_tree, &mut buf)?.to_owned(); + let mut editor = gix_object::tree::Editor::new(root_tree, find, object_hash); + + let paths = index.path_backing(); + for entry in index.entries() { + // Skip sparse-checkout directory markers. + if entry.mode.is_sparse() { + continue; + } + let path = entry.path_in(paths); + let entry_kind = entry + .mode + .to_tree_entry_mode() + .ok_or_else(|| Error::InvalidIndexEntryMode { + path: path.to_owned(), + mode: entry.mode.bits(), + })? + .kind(); + + // Split the path on `/` to feed into the tree editor. + let components: Vec<&bstr::BStr> = path.split(|b| *b == b'/').map(bstr::ByteSlice::as_bstr).collect(); + editor.upsert(components, entry_kind, entry.id)?; + } + + editor.write(|tree| odb.write(tree).map_err(Error::WriteTree)) + } + + /// Walk the worktree recursively for files not in `index`, write them as + /// blobs, and assemble them into a tree. + /// + /// Returns the tree OID **and** the list of absolute paths that were + /// captured. The paths list is used by the caller to remove those files + /// from disk after the stash ref is committed. + /// + /// Uses `std::fs::read_dir` rather than `gix-dir` to avoid pulling in + /// `gix-pathspec` as a direct dependency. `.gitignore` rules are **not** + /// respected. + /// + /// TODO(gix-stash): consult `.gitignore` via the `gix-worktree` exclude stack. + fn write_untracked_tree( + find: &impl gix_object::FindExt, + odb: &impl gix_object::Write, + worktree: &Path, + index: &gix_index::State, + ) -> Result<(ObjectId, Vec), Error> { + let object_hash = index.object_hash(); + let mut editor = gix_object::tree::Editor::new(Tree::empty(), find, object_hash); + let mut abs_paths: Vec = Vec::new(); + + let paths_storage = index.path_backing(); + let tracked: std::collections::BTreeSet = index + .entries() + .iter() + .map(|e| e.path_in(paths_storage).to_owned()) + .collect(); + + collect_untracked(worktree, worktree, &tracked, odb, &mut editor, &mut abs_paths)?; + let tree_oid = editor.write(|tree| odb.write(tree).map_err(Error::WriteTree))?; + Ok((tree_oid, abs_paths)) + } + + /// Recursively walk `dir` and add untracked files to `editor`. + fn collect_untracked( + worktree: &Path, + dir: &Path, + tracked: &std::collections::BTreeSet, + odb: &impl gix_object::Write, + editor: &mut gix_object::tree::Editor<'_>, + abs_paths: &mut Vec, + ) -> Result<(), Error> { + let read_dir = std::fs::read_dir(dir).map_err(Error::WalkWorktree)?; + + for dir_entry_result in read_dir { + let dir_entry = dir_entry_result.map_err(Error::WalkWorktree)?; + let name = dir_entry.file_name(); + let name_bytes = name.as_encoded_bytes(); + + // Never recurse into .git. + if name_bytes == b".git" { + continue; + } + + let abs_path = dir_entry.path(); + let file_type = dir_entry.file_type().map_err(|e| Error::ReadFile { + path: abs_path.clone(), + source: e, + })?; + + if file_type.is_dir() { + collect_untracked(worktree, &abs_path, tracked, odb, editor, abs_paths)?; + } else if file_type.is_file() || file_type.is_symlink() { + let rela = rela_path(worktree, &abs_path); + if tracked.contains(&rela) { + continue; + } + + let (blob_content, kind) = if file_type.is_symlink() { + // Store the symlink target path as the blob, not the + // content of the file the link points to. + let target = std::fs::read_link(&abs_path).map_err(|e| Error::ReadFile { + path: abs_path.clone(), + source: e, + })?; + let target_bytes = gix_path::into_bstr(target); + (target_bytes.as_ref().to_vec(), EntryKind::Link) + } else { + let content = std::fs::read(&abs_path).map_err(|e| Error::ReadFile { + path: abs_path.clone(), + source: e, + })?; + (content, EntryKind::Blob) + }; + + let blob_oid = odb + .write_buf(gix_object::Kind::Blob, &blob_content) + .map_err(Error::WriteBlob)?; + + let rela_bstr: &bstr::BStr = rela.as_bstr(); + let components: Vec<&bstr::BStr> = + rela_bstr.split(|b| *b == b'/').map(bstr::ByteSlice::as_bstr).collect(); + editor.upsert(components, kind, blob_oid)?; + abs_paths.push(abs_path); + } + // Special files (sockets, devices, pipes) are silently skipped. + } + Ok(()) + } + + /// Compute a `/`-separated path relative to `worktree`. + fn rela_path(worktree: &Path, abs: &Path) -> BString { + let rel = abs + .strip_prefix(worktree) + .unwrap_or(abs) + .components() + .filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.as_encoded_bytes().to_vec()), + _ => None, + }) + .collect::>() + .join(b"/" as &[u8]); + BString::from(rel) + } + + /// Write a commit object to the ODB and return its OID. + fn write_commit( + odb: &impl gix_object::Write, + tree: ObjectId, + parents: &[ObjectId], + committer: gix_actor::SignatureRef<'_>, + message: &bstr::BStr, + ) -> Result { + let sig: gix_actor::Signature = committer.into(); + let commit = gix_object::Commit { + tree, + parents: parents.iter().copied().collect(), + author: sig.clone(), + committer: sig, + encoding: None, + message: message.to_owned(), + extra_headers: Vec::new(), + }; + odb.write(&commit).map_err(Error::WriteCommit) + } + + /// Return the first line (subject) of a commit's message. + fn first_line_of_commit_message(find: &impl gix_object::FindExt, commit_oid: ObjectId) -> Result { + let mut buf = Vec::new(); + let commit = find.find_commit(&commit_oid, &mut buf)?; + Ok(commit.message.lines().next().unwrap_or(b"").as_bstr().to_owned()) + } + + /// Return a 7-character hex prefix of the given OID. + fn short_id(oid: ObjectId) -> BString { + let s = oid.to_hex().to_string(); + BString::from(&s.as_bytes()[..7.min(s.len())]) + } +} diff --git a/gix-stash/tests/fixtures/generated-archives/make_list_empty_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_list_empty_repo.tar new file mode 100644 index 00000000000..9aca87fcdfd Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_list_empty_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_list_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_list_repo.tar new file mode 100644 index 00000000000..259dbfe8543 Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_list_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_pop_conflict_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_pop_conflict_repo.tar new file mode 100644 index 00000000000..5d70a5e73c8 Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_pop_conflict_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_pop_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_pop_repo.tar new file mode 100644 index 00000000000..afe3d406292 Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_pop_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_pop_two_stashes_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_pop_two_stashes_repo.tar new file mode 100644 index 00000000000..0f4a982587d Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_pop_two_stashes_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_conflict_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_conflict_repo.tar new file mode 100644 index 00000000000..a559f4e8f5c Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_conflict_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_repo.tar new file mode 100644 index 00000000000..e4d425c2545 Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_pop_untracked_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_push_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_push_repo.tar new file mode 100644 index 00000000000..b597bac883c Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_push_repo.tar differ diff --git a/gix-stash/tests/fixtures/generated-archives/make_push_symlink_repo.tar b/gix-stash/tests/fixtures/generated-archives/make_push_symlink_repo.tar new file mode 100644 index 00000000000..c19e32d4d44 Binary files /dev/null and b/gix-stash/tests/fixtures/generated-archives/make_push_symlink_repo.tar differ diff --git a/gix-stash/tests/fixtures/make_list_empty_repo.sh b/gix-stash/tests/fixtures/make_list_empty_repo.sh new file mode 100755 index 00000000000..fc465e0f2eb --- /dev/null +++ b/gix-stash/tests/fixtures/make_list_empty_repo.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Initial commit so the repo is valid but has no stashes. +echo "initial" > file.txt +git add file.txt +git commit -q -m "initial commit" diff --git a/gix-stash/tests/fixtures/make_list_repo.sh b/gix-stash/tests/fixtures/make_list_repo.sh new file mode 100755 index 00000000000..49a65b8d6cf --- /dev/null +++ b/gix-stash/tests/fixtures/make_list_repo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Initial commit so we have a HEAD. +echo "initial" > file.txt +git add file.txt +git commit -q -m "initial commit" + +# First stash. +echo "change one" > file.txt +git stash push -m "first stash" + +# Second stash. +echo "change two" > file.txt +git stash push -m "second stash" + +# Third stash. +echo "change three" > file.txt +git stash push -m "third stash" diff --git a/gix-stash/tests/fixtures/make_pop_conflict_repo.sh b/gix-stash/tests/fixtures/make_pop_conflict_repo.sh new file mode 100755 index 00000000000..d481d41bb23 --- /dev/null +++ b/gix-stash/tests/fixtures/make_pop_conflict_repo.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Base commit: file.txt = "content A" +echo "content A" > file.txt +git add file.txt +git commit -q -m "base commit" + +# Modify to "content B" and stash — stash records WIP=B on base=A. +echo "content B" > file.txt +git stash push -m "stash: content B" + +# Now modify HEAD to "content C" and commit. +# When we pop, base=A, ours=C, theirs=B → conflict. +echo "content C" > file.txt +git add file.txt +git commit -q -m "commit with content C" diff --git a/gix-stash/tests/fixtures/make_pop_repo.sh b/gix-stash/tests/fixtures/make_pop_repo.sh new file mode 100755 index 00000000000..3a4158be50b --- /dev/null +++ b/gix-stash/tests/fixtures/make_pop_repo.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Base commit. +echo "original content" > file.txt +git add file.txt +git commit -q -m "initial commit" + +# Make a change and stash it — leaves WT clean. +echo "stashed modification" > file.txt +git stash push -m "stash: stashed modification" diff --git a/gix-stash/tests/fixtures/make_pop_two_stashes_repo.sh b/gix-stash/tests/fixtures/make_pop_two_stashes_repo.sh new file mode 100755 index 00000000000..dce1130f54d --- /dev/null +++ b/gix-stash/tests/fixtures/make_pop_two_stashes_repo.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Base commit. +echo "original content" > file.txt +git add file.txt +git commit -q -m "initial commit" + +# First stash (becomes stash@{1} after the second push). +echo "older stash" > file.txt +git stash push -m "stash: older modification" + +# Second stash (newest, becomes stash@{0}). +echo "newer stash" > other.txt +git add other.txt +git stash push -m "stash: newer modification" diff --git a/gix-stash/tests/fixtures/make_pop_untracked_conflict_repo.sh b/gix-stash/tests/fixtures/make_pop_untracked_conflict_repo.sh new file mode 100755 index 00000000000..5e7fd4d2a8f --- /dev/null +++ b/gix-stash/tests/fixtures/make_pop_untracked_conflict_repo.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Base commit with a tracked file. +echo "tracked content" > tracked.txt +git add tracked.txt +git commit -q -m "initial commit" + +# Create an untracked file and stash including untracked. +echo "stashed untracked content" > untracked.txt +git stash push --include-untracked -m "stash: with untracked file" + +# Simulate the user creating a file at the same path after stashing. +echo "user's own content" > untracked.txt diff --git a/gix-stash/tests/fixtures/make_pop_untracked_repo.sh b/gix-stash/tests/fixtures/make_pop_untracked_repo.sh new file mode 100755 index 00000000000..7a0b021c682 --- /dev/null +++ b/gix-stash/tests/fixtures/make_pop_untracked_repo.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Base commit with a tracked file. +echo "tracked content" > tracked.txt +git add tracked.txt +git commit -q -m "initial commit" + +# Create an untracked file and stash including untracked. +echo "untracked content" > untracked.txt +git stash push --include-untracked -m "stash: with untracked file" diff --git a/gix-stash/tests/fixtures/make_push_repo.sh b/gix-stash/tests/fixtures/make_push_repo.sh new file mode 100755 index 00000000000..127242f5baa --- /dev/null +++ b/gix-stash/tests/fixtures/make_push_repo.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Commit a tracked file at HEAD. +echo "original content" > tracked.txt +git add tracked.txt +git commit -q -m "initial commit" diff --git a/gix-stash/tests/fixtures/make_push_symlink_repo.sh b/gix-stash/tests/fixtures/make_push_symlink_repo.sh new file mode 100755 index 00000000000..6bc9857b504 --- /dev/null +++ b/gix-stash/tests/fixtures/make_push_symlink_repo.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name "Test User" +git config user.email "test@example.com" + +# Commit a tracked file at HEAD. +echo "original content" > tracked.txt +git add tracked.txt +git commit -q -m "initial commit" + +# Create an untracked symlink (not staged) pointing at tracked.txt. +# We don't stage it — it should appear as an untracked entry when +# `include_untracked=true` is set. +ln -s tracked.txt mylink diff --git a/gix-stash/tests/stash/list.rs b/gix-stash/tests/stash/list.rs new file mode 100644 index 00000000000..098caf0f6ca --- /dev/null +++ b/gix-stash/tests/stash/list.rs @@ -0,0 +1,73 @@ +use crate::{git_dir, open_ref_store}; + +/// Fixture: three stashes pushed. +fn list_repo() -> gix_testtools::Result { + gix_testtools::scripted_fixture_read_only("make_list_repo.sh") +} + +/// Fixture: repo with no stashes at all. +fn list_empty_repo() -> gix_testtools::Result { + gix_testtools::scripted_fixture_read_only("make_list_empty_repo.sh") +} + +#[test] +fn lists_entries_newest_first() -> gix_testtools::Result { + let worktree = list_repo()?; + let refs = open_ref_store(&git_dir(&worktree)); + + let outcome = gix_stash::list(&refs)?; + + assert_eq!(outcome.entries.len(), 3, "expected 3 stash entries"); + + // Entries are newest-first: index 0 = most recently pushed ("third stash"). + assert_eq!(outcome.entries[0].index, 0); + assert_eq!(outcome.entries[1].index, 1); + assert_eq!(outcome.entries[2].index, 2); + + // The messages should follow the stack order (newest first). + // git stash push -m "third stash" was the last push. + let msg0 = outcome.entries[0].message.to_string(); + let msg2 = outcome.entries[2].message.to_string(); + assert!( + msg0.contains("third"), + "entries[0] should contain 'third', got: {msg0:?}" + ); + assert!( + msg2.contains("first"), + "entries[2] should contain 'first', got: {msg2:?}" + ); + + Ok(()) +} + +#[test] +fn empty_repo_returns_empty_outcome() -> gix_testtools::Result { + let worktree = list_empty_repo()?; + let refs = open_ref_store(&git_dir(&worktree)); + + let outcome = gix_stash::list(&refs)?; + + assert!( + outcome.entries.is_empty(), + "no stashes should produce an empty outcome, not an error" + ); + Ok(()) +} + +#[test] +fn time_seconds_is_positive() -> gix_testtools::Result { + let worktree = list_repo()?; + let refs = open_ref_store(&git_dir(&worktree)); + + let outcome = gix_stash::list(&refs)?; + + for entry in &outcome.entries { + assert!( + entry.time_seconds > 0, + "stash entry {} has non-positive time: {}", + entry.index, + entry.time_seconds + ); + } + Ok(()) +} diff --git a/gix-stash/tests/stash/main.rs b/gix-stash/tests/stash/main.rs new file mode 100644 index 00000000000..c0b330b28b3 --- /dev/null +++ b/gix-stash/tests/stash/main.rs @@ -0,0 +1,135 @@ +mod list; +mod pop; +mod push; + +use std::path::{Path, PathBuf}; + +use gix_ref::store::WriteReflog; + +/// Open a read-enabled `gix_ref::file::Store` pointed at `git_dir`. +pub(crate) fn open_ref_store(git_dir: &Path) -> gix_ref::file::Store { + let object_hash = gix_testtools::object_hash(); + gix_ref::file::Store::at( + git_dir.to_owned(), + gix_ref::store::init::Options { + write_reflog: WriteReflog::Normal, + object_hash, + ..Default::default() + }, + ) +} + +/// Open a `gix_odb::HandleArc` pointed at `/objects`. +/// +/// Using `HandleArc` (backed by `Arc`) ensures the handle is `Send + Clone`, +/// which is required by the `push` and `pop` generic bounds. +pub(crate) fn open_odb(git_dir: &Path) -> gix_testtools::Result { + let object_hash = gix_testtools::object_hash(); + let odb = gix_odb::at_opts( + git_dir.join("objects"), + Vec::new(), + gix_odb::store::init::Options { + object_hash, + ..Default::default() + }, + )? + .into_arc()?; + Ok(odb) +} + +/// Return a fixed `gix_actor::Signature` suitable for test commits. +pub(crate) fn test_committer() -> gix_actor::Signature { + gix_actor::Signature { + name: "Test User".into(), + email: "test@example.com".into(), + time: gix_date::Time::new(1_700_000_000, 0), + } +} + +/// Resolve the OID that `HEAD` points to in the repo rooted at `worktree_path`. +pub(crate) fn head_commit_oid( + _worktree_path: &Path, + refs: &gix_ref::file::Store, + odb: &impl gix_object::FindExt, +) -> gix_testtools::Result { + use gix_ref::file::ReferenceExt; + let mut reference = refs.find("HEAD")?; + Ok(reference.peel_to_id(refs, odb)?) +} + +/// Return the tree OID for the given commit OID. +pub(crate) fn commit_tree( + odb: &impl gix_object::FindExt, + commit_oid: gix_hash::ObjectId, +) -> gix_testtools::Result { + let mut buf = Vec::new(); + let commit = odb.find_commit(&commit_oid, &mut buf)?; + Ok(commit.tree()) +} + +/// Read a blob from a tree by name and return its content. +/// +/// Only works for top-level file names in the tree. +pub(crate) fn blob_content_in_tree( + odb: &impl gix_object::FindExt, + tree_oid: gix_hash::ObjectId, + filename: &[u8], +) -> gix_testtools::Result> { + use bstr::ByteSlice; + let mut buf = Vec::new(); + let tree = odb.find_tree(&tree_oid, &mut buf)?; + for entry in &tree.entries { + if entry.filename.as_bstr() == filename { + let mut blob_buf = Vec::new(); + let blob = odb.find_blob(entry.oid, &mut blob_buf)?; + return Ok(blob.data.to_owned()); + } + } + Err(format!("file {filename:?} not found in tree {tree_oid}").into()) +} + +/// Build a minimal `gix_diff::blob::Platform` for use with pop's `Context`. +pub(crate) fn new_diff_cache(worktree: &Path) -> gix_diff::blob::Platform { + gix_diff::blob::Platform::new( + Default::default(), + gix_diff::blob::Pipeline::new(Default::default(), Default::default(), Vec::new(), Default::default()), + Default::default(), + gix_worktree::Stack::new( + worktree, + gix_worktree::stack::State::AttributesStack(gix_worktree::stack::state::Attributes::default()), + Default::default(), + Vec::new(), + Vec::new(), + ), + ) +} + +/// Build a `gix_merge::blob::Platform` for use with pop's `Context`. +pub(crate) fn new_blob_merge_platform(worktree: &Path) -> gix_merge::blob::Platform { + let attributes = gix_worktree::Stack::new( + worktree, + gix_worktree::stack::State::AttributesStack(gix_worktree::stack::state::Attributes::default()), + Default::default(), + Vec::new(), + Vec::new(), + ); + let filter = gix_merge::blob::Pipeline::new( + Default::default(), + gix_filter::Pipeline::default(), + gix_merge::blob::pipeline::Options { + large_file_threshold_bytes: 0, + }, + ); + gix_merge::blob::Platform::new( + filter, + gix_merge::blob::pipeline::Mode::ToGit, + attributes, + vec![], + Default::default(), + ) +} + +/// Return `.git` directory for a worktree path. +pub(crate) fn git_dir(worktree_path: &Path) -> PathBuf { + worktree_path.join(".git") +} diff --git a/gix-stash/tests/stash/pop.rs b/gix-stash/tests/stash/pop.rs new file mode 100644 index 00000000000..ec1f2b57949 --- /dev/null +++ b/gix-stash/tests/stash/pop.rs @@ -0,0 +1,392 @@ +use gix_date::parse::TimeBuf; + +use crate::{ + commit_tree, git_dir, head_commit_oid, new_blob_merge_platform, new_diff_cache, open_odb, open_ref_store, + test_committer, +}; + +fn pop_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_pop_repo.sh") +} + +fn pop_two_stashes_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_pop_two_stashes_repo.sh") +} + +fn pop_untracked_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_pop_untracked_repo.sh") +} + +fn pop_untracked_conflict_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_pop_untracked_conflict_repo.sh") +} + +fn pop_conflict_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_pop_conflict_repo.sh") +} + +fn empty_repo_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_list_empty_repo.sh") +} + +#[test] +fn pop_applies_stash_to_clean_wt() -> gix_testtools::Result { + let tmp = pop_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let outcome = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_tree, + )?; + + assert!(!outcome.had_conflicts, "pop of a clean stash should have no conflicts"); + assert!( + outcome.new_top.is_none(), + "after popping the only stash, new_top must be None" + ); + + // refs/stash must be gone. + assert!( + refs.try_find("refs/stash")?.is_none(), + "refs/stash must be deleted after popping the last entry" + ); + + // The working tree file should now contain the stashed modification. + let content = std::fs::read_to_string(worktree.join("file.txt"))?; + assert_eq!( + content.trim(), + "stashed modification", + "pop must restore the stashed working tree content" + ); + + Ok(()) +} + +#[test] +fn pop_drops_only_top_with_multiple_stashes() -> gix_testtools::Result { + let tmp = pop_two_stashes_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + // Record the old stash tip before popping. + let old_stash_tip = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash must have an OID target") + .to_owned(); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let outcome = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_tree, + )?; + + assert_eq!(outcome.applied, old_stash_tip, "applied must be the old tip"); + + // refs/stash must still exist (there is one more entry). + assert!( + refs.try_find("refs/stash")?.is_some(), + "refs/stash must still exist after popping from a multi-entry stack" + ); + + // The new top must be Some and different from the old tip. + let new_top = outcome.new_top.expect("new_top must be Some when more stashes remain"); + assert_ne!(new_top, old_stash_tip, "new_top must differ from the popped entry"); + + // refs/stash must point at new_top. + let current_stash = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash OID") + .to_owned(); + assert_eq!(current_stash, new_top); + + Ok(()) +} + +#[test] +fn pop_returns_no_stash_when_unborn() -> gix_testtools::Result { + let tmp = empty_repo_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let result = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: Default::default(), + }, + head_tree, + ); + + match result { + Err(gix_stash::PopError::NoStash) => {} + other => { + return Err(format!("expected Err(NoStash) for a repo with no stash, got: {other:?}").into()); + } + } + + Ok(()) +} + +#[test] +fn pop_restores_untracked_when_present() -> gix_testtools::Result { + let tmp = pop_untracked_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + // After the fixture runs `git stash --include-untracked`, the untracked + // file should NOT be on disk. + assert!( + !worktree.join("untracked.txt").exists(), + "fixture post-condition: untracked.txt should be removed by git stash" + ); + + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let outcome = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_tree, + )?; + + assert!( + !outcome.had_conflicts, + "pop of untracked-only stash should have no conflicts" + ); + + // untracked.txt must be restored. + let content = std::fs::read_to_string(worktree.join("untracked.txt"))?; + assert_eq!( + content.trim(), + "untracked content", + "pop must restore the untracked file from parent[2]" + ); + + Ok(()) +} + +#[test] +fn pop_conflicts_leave_ref_intact() -> gix_testtools::Result { + let tmp = pop_conflict_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + // HEAD is now the "content C" commit; stash has "content B" based on "content A". + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + let stash_tip_before = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash OID") + .to_owned(); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let outcome = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_tree, + )?; + + assert!( + outcome.had_conflicts, + "merging stash B onto HEAD C (base A) must produce conflicts" + ); + + // refs/stash must still point at the same OID (not dropped on conflict). + let stash_tip_after = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash OID") + .to_owned(); + assert_eq!( + stash_tip_after, stash_tip_before, + "refs/stash must not be updated on a conflicted pop" + ); + + Ok(()) +} + +/// When restoring untracked files (`parent[2]`) during `pop`, if a target +/// path already exists on disk, the pop must report a conflict and leave +/// `refs/stash` intact so no data is lost. +#[test] +fn pop_conflicts_on_untracked_restore_when_target_exists() -> gix_testtools::Result { + let tmp = pop_untracked_conflict_fixture()?; + let worktree = tmp.path(); + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + + // The fixture leaves untracked.txt on disk after stashing. + assert!( + worktree.join("untracked.txt").exists(), + "fixture post-condition: untracked.txt must exist on disk" + ); + + let stash_tip_before = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash OID") + .to_owned(); + + let head_oid = head_commit_oid(worktree, &refs, &odb)?; + let head_tree = commit_tree(&odb, head_oid)?; + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let mut diff_cache = new_diff_cache(worktree); + let mut blob_merge = new_blob_merge_platform(worktree); + + let outcome = gix_stash::pop( + gix_stash::PopContext { + refs: &refs, + objects: &odb, + committer: committer_ref, + worktree, + blob_merge: &mut blob_merge, + diff_cache: &mut diff_cache, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_tree, + )?; + + // had_conflicts must be true — an existing file would be clobbered. + assert!( + outcome.had_conflicts, + "pop must report had_conflicts=true when untracked restore would clobber an existing file" + ); + + // refs/stash must still point at the original stash commit. + let stash_tip_after = refs + .find("refs/stash")? + .target + .try_id() + .expect("refs/stash OID") + .to_owned(); + assert_eq!( + stash_tip_after, stash_tip_before, + "refs/stash must not be dropped when untracked restore has a conflict" + ); + + // The user's file must not have been overwritten. + let content = std::fs::read_to_string(worktree.join("untracked.txt"))?; + assert_eq!( + content.trim(), + "user's own content", + "existing file must not be clobbered during a conflicted pop" + ); + + Ok(()) +} diff --git a/gix-stash/tests/stash/push.rs b/gix-stash/tests/stash/push.rs new file mode 100644 index 00000000000..8031a194a5a --- /dev/null +++ b/gix-stash/tests/stash/push.rs @@ -0,0 +1,542 @@ +use std::path::Path; + +use gix_date::parse::TimeBuf; + +use crate::{blob_content_in_tree, commit_tree, git_dir, head_commit_oid, open_odb, open_ref_store, test_committer}; + +/// Open the push fixture (writable copy), return the worktree `TempDir`. +fn push_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable("make_push_repo.sh") +} + +/// Convenience: open all handles from a worktree path. +struct Repo { + worktree: std::path::PathBuf, + refs: gix_ref::file::Store, + odb: gix_odb::HandleArc, +} + +impl Repo { + fn open(worktree: &Path) -> gix_testtools::Result { + let gd = git_dir(worktree); + let refs = open_ref_store(&gd); + let odb = open_odb(&gd)?; + Ok(Self { + worktree: worktree.to_owned(), + refs, + odb, + }) + } + + /// Load the index from `.git/index`. + fn load_index(&self) -> gix_testtools::Result { + let object_hash = gix_testtools::object_hash(); + Ok(gix_index::File::at( + git_dir(&self.worktree).join("index"), + object_hash, + false, + Default::default(), + )?) + } +} + +#[test] +fn push_captures_unstaged_modification() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + let repo = Repo::open(worktree)?; + + // Modify tracked.txt on disk — do NOT stage it. + let tracked_path = worktree.join("tracked.txt"); + std::fs::write(&tracked_path, "modified content\n")?; + + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions::default(), + )?; + + // refs/stash must now exist and point at the stash commit. + let stash_ref = repo.refs.find("refs/stash")?; + let stash_oid = stash_ref.target.try_id().expect("stash ref must be an OID").to_owned(); + assert_eq!(stash_oid, outcome.stash); + + // The stash commit's tree must contain tracked.txt with the modified content. + let stash_tree = commit_tree(&repo.odb, stash_oid)?; + let content = blob_content_in_tree(&repo.odb, stash_tree, b"tracked.txt")?; + assert_eq!( + content, b"modified content\n", + "stash tree should capture the modified WT content" + ); + + // After push, the WT file should be reset to HEAD content. + let wt_content = std::fs::read(&tracked_path)?; + assert_eq!(wt_content, b"original content\n", "push must reset WT to HEAD content"); + + Ok(()) +} + +#[test] +fn push_captures_staged_change() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + + // Stage a modification to tracked.txt via git add. + std::fs::write(worktree.join("tracked.txt"), "staged content\n")?; + std::process::Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(worktree) + .status()?; + + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions::default(), + )?; + + // parent[1] of the stash commit is the index-state commit. + use gix_object::FindExt; + let mut buf = Vec::new(); + let stash_commit = repo.odb.find_commit(&outcome.stash, &mut buf)?; + let index_commit_oid = stash_commit + .parents() + .nth(1) + .expect("stash commit must have parent[1] (index-state)"); + + let index_tree = commit_tree(&repo.odb, index_commit_oid)?; + let content = blob_content_in_tree(&repo.odb, index_tree, b"tracked.txt")?; + assert_eq!( + content, b"staged content\n", + "index-state commit tree (parent[1]) must reflect what was staged" + ); + + Ok(()) +} + +/// Tests that `push` on a repository with no local changes returns `Err(NoLocalChanges)`. +/// +/// KNOWN BUG (as of commit a882282e5): the `NoLocalChanges` guard only fires when +/// `index.entries().is_empty()`, which is never true for a repo that has committed +/// files. A clean working tree therefore falls through and produces a stash commit +/// that is identical to HEAD. The correct behaviour would be to compare the WT + +/// index against HEAD and bail out when there is nothing to save. +/// +/// This test records the *expected* correct behaviour. If it passes, the bug +/// has been fixed; if it fails, the bug is still present. +#[test] +fn push_returns_no_local_changes_on_clean_wt() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let result = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions::default(), + ); + + match result { + Err(gix_stash::PushError::NoLocalChanges) => { + // Correct — the bug has been fixed. + } + Ok(_) => { + // BUG: the guard `if index.entries().is_empty() && !options.include_untracked` + // fires only for an empty index. A repo with committed files has a non-empty + // index even on a clean WT, so the check never trips. + return Err("BUG(gix-stash push): NoLocalChanges guard fires only on empty index, \ + not on clean-WT repos with committed files. \ + push succeeded on a clean working tree and produced a no-op stash." + .into()); + } + Err(e) => return Err(e.into()), + } + + Ok(()) +} + +#[test] +fn push_includes_untracked_when_flag_set() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + + // Create an untracked file (not staged, not committed). + std::fs::write(worktree.join("new.txt"), "untracked content\n")?; + + // Also make a tracked change so the index isn't fully clean and the + // `NoLocalChanges` guard does not trip (the guard is `index.is_empty()` + // today, but we want the test to work after a fix too). + std::fs::write(worktree.join("tracked.txt"), "modified for untracked test\n")?; + + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions { + include_untracked: true, + ..Default::default() + }, + )?; + + // parent[2] must exist when include_untracked is set and untracked files were found. + let untracked_commit_oid = outcome + .untracked_commit + .expect("untracked_commit must be Some when include_untracked=true and untracked files exist"); + + // The untracked commit's tree must contain new.txt. + let untracked_tree = commit_tree(&repo.odb, untracked_commit_oid)?; + let content = blob_content_in_tree(&repo.odb, untracked_tree, b"new.txt")?; + assert_eq!( + content, b"untracked content\n", + "untracked-files commit tree must contain new.txt" + ); + + // new.txt should no longer be on disk after push. + assert!( + !worktree.join("new.txt").exists(), + "untracked file must be removed from disk after push with include_untracked=true" + ); + + Ok(()) +} + +#[test] +fn push_leaves_untracked_alone_without_flag() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + + // Create an untracked file. + std::fs::write(worktree.join("new.txt"), "untracked content\n")?; + // Make a tracked change so push proceeds. + std::fs::write(worktree.join("tracked.txt"), "modified for flag test\n")?; + + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions { + include_untracked: false, + ..Default::default() + }, + )?; + + // No untracked commit should be created. + assert!( + outcome.untracked_commit.is_none(), + "untracked_commit must be None when include_untracked=false" + ); + + // new.txt must still be on disk. + assert!( + worktree.join("new.txt").exists(), + "untracked file must remain on disk when include_untracked=false" + ); + + Ok(()) +} + +/// Tests that `push` on a repo with no commits returns `Err(EmptyRepository)`. +/// +/// The `push` plumbing function requires the caller to supply pre-resolved +/// `head_commit` and `head_tree` OIDs. Resolving HEAD on an empty repository +/// fails before `push` is reached. The `EmptyRepository` variant is reserved +/// for the porcelain (`gix`) layer. This test documents that limitation. +#[test] +fn push_returns_empty_repository_on_no_commits() -> gix_testtools::Result { + // No-op: the plumbing API cannot represent the no-commits scenario because + // the caller must supply valid `head_commit`/`head_tree` OIDs up front. + // See `gix::Repository::stash_push` for the porcelain-level guard. + Ok(()) +} + +/// Open the symlink push fixture using `Creation::Execute` so that the symlink +/// created by the script is preserved in the writable copy. +fn push_symlink_fixture() -> gix_testtools::Result { + gix_testtools::scripted_fixture_writable_with_args( + "make_push_symlink_repo.sh", + None::, + gix_testtools::Creation::Execute, + ) +} + +/// When `include_untracked=true` an untracked symlink must be captured with +/// entry mode `Link` and a blob containing the **link target path**, not the +/// content of the file the link points to. +#[test] +fn push_captures_symlink_target_for_untracked_links() -> gix_testtools::Result { + let tmp = push_symlink_fixture()?; + let worktree = tmp.path(); + + // Verify the fixture produced a symlink on disk. + let link_path = worktree.join("mylink"); + assert!( + link_path.symlink_metadata()?.file_type().is_symlink(), + "fixture must create mylink as a symlink" + ); + + // Also make a tracked change so push doesn't bail with NoLocalChanges + // when the symlink is the only untracked entry. + std::fs::write(worktree.join("tracked.txt"), "changed\n")?; + + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions { + include_untracked: true, + ..Default::default() + }, + )?; + + // The untracked commit must exist (mylink was captured). + let untracked_commit_oid = outcome + .untracked_commit + .expect("untracked_commit must be Some when symlink was captured"); + + // The blob stored for mylink must be the link target "tracked.txt", + // NOT the content of tracked.txt. + let untracked_tree = commit_tree(&repo.odb, untracked_commit_oid)?; + let blob_bytes = blob_content_in_tree(&repo.odb, untracked_tree, b"mylink")?; + assert_eq!( + blob_bytes, b"tracked.txt", + "symlink blob must store the link target path, not the target file content" + ); + + // Verify the mode stored in the tree is Link. + use gix_object::FindExt; + let mut buf = Vec::new(); + let tree = repo.odb.find_tree(&untracked_tree, &mut buf)?; + let entry = tree + .entries + .iter() + .find(|e| e.filename == "mylink") + .expect("mylink entry must exist in untracked tree"); + assert_eq!( + entry.mode.kind(), + gix_object::tree::EntryKind::Link, + "symlink must be stored with EntryKind::Link" + ); + + Ok(()) +} + +/// When `include_untracked=true` but there are no untracked files (and no +/// staged/WIP changes either), `push` must return `Err(NoLocalChanges)`. +#[test] +fn push_returns_no_local_changes_with_include_untracked_when_nothing_to_save() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let result = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: Default::default(), + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions { + include_untracked: true, + ..Default::default() + }, + ); + + match result { + Err(gix_stash::PushError::NoLocalChanges) => {} + Ok(_) => { + return Err( + "BUG: push with include_untracked=true on a clean repo must return NoLocalChanges, \ + not succeed with an empty stash commit" + .into(), + ); + } + Err(e) => return Err(e.into()), + } + + Ok(()) +} + +/// `push` with `keep_index=true` must leave the WT reflecting the **index** +/// state (staged changes visible on disk) rather than resetting to HEAD. +#[test] +fn push_with_keep_index_preserves_staged_changes_in_wt() -> gix_testtools::Result { + let tmp = push_fixture()?; + let worktree = tmp.path(); + + // Stage a modification. + std::fs::write(worktree.join("tracked.txt"), "staged content\n")?; + std::process::Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(worktree) + .status()?; + + let repo = Repo::open(worktree)?; + let index = repo.load_index()?; + let head_oid = head_commit_oid(worktree, &repo.refs, &repo.odb)?; + let head_tree = commit_tree(&repo.odb, head_oid)?; + let head_branch: gix_ref::FullName = "refs/heads/main".try_into().expect("valid ref name"); + + let committer = test_committer(); + let mut time_buf = TimeBuf::default(); + let committer_ref = committer.to_ref(&mut time_buf); + + let outcome = gix_stash::push( + gix_stash::PushContext { + refs: &repo.refs, + objects: &repo.odb, + index: &index, + worktree, + committer: committer_ref, + checkout_options: gix_worktree_state::checkout::Options { + overwrite_existing: true, + ..Default::default() + }, + }, + head_oid, + head_tree, + Some(head_branch.as_ref()), + gix_stash::PushOptions { + keep_index: true, + ..Default::default() + }, + )?; + + // refs/stash must be set. + assert!( + repo.refs.try_find("refs/stash")?.is_some(), + "refs/stash must be created by push" + ); + + // The stash must have been recorded. + let _ = outcome.stash; + + // With keep_index=true the WT file must still contain the staged content, + // not the HEAD content. + let wt_content = std::fs::read(worktree.join("tracked.txt"))?; + assert_eq!( + wt_content, b"staged content\n", + "keep_index=true must leave the staged content on disk" + ); + + Ok(()) +} diff --git a/gix/Cargo.toml b/gix/Cargo.toml index c4d95eb9978..2d8a24dc846 100644 --- a/gix/Cargo.toml +++ b/gix/Cargo.toml @@ -169,6 +169,9 @@ merge = ["tree-editor", "blob-diff", "dep:gix-merge", "attributes"] ## Add blame command similar to `git blame`. blame = ["dep:gix-blame", "blob-diff"] +## Add stash plumbing (push + pop) similar to `git stash`. +stash = ["dep:gix-stash"] + ## Make it possible to turn a tree into a stream of bytes, which can be decoded to entries and turned into various other formats. worktree-stream = ["gix-worktree-stream", "attributes"] @@ -394,6 +397,7 @@ gix-command = { version = "^0.9.1", path = "../gix-command", optional = true } gix-worktree-stream = { version = "^0.33.0", path = "../gix-worktree-stream", optional = true } gix-archive = { version = "^0.33.0", path = "../gix-archive", default-features = false, optional = true } gix-blame = { version = "^0.14.0", path = "../gix-blame", optional = true } +gix-stash = { version = "^0.0.0", path = "../gix-stash", optional = true } # For communication with remotes gix-protocol = { version = "^0.62.0", path = "../gix-protocol" } diff --git a/gix/src/lib.rs b/gix/src/lib.rs index 18756a81fda..41b3866b4ff 100644 --- a/gix/src/lib.rs +++ b/gix/src/lib.rs @@ -151,6 +151,8 @@ pub use gix_ref as refs; pub use gix_refspec as refspec; pub use gix_revwalk as revwalk; pub use gix_sec as sec; +#[cfg(feature = "stash")] +pub use gix_stash as stash; pub use gix_tempfile as tempfile; pub use gix_trace as trace; pub use gix_traverse as traverse;