diff --git a/Cargo.lock b/Cargo.lock index 955e05ada1..98f8b31407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1984,9 +1984,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.34" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", "libz-ng-sys", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "gix" version = "0.70.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-actor 0.33.2", "gix-attributes 0.24.0", @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "gix-actor" version = "0.33.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-date 0.9.3", @@ -3230,7 +3230,7 @@ dependencies = [ [[package]] name = "gix-attributes" version = "0.24.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-glob 0.18.0", @@ -3256,7 +3256,7 @@ dependencies = [ [[package]] name = "gix-bitmap" version = "0.2.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "thiserror 2.0.9", ] @@ -3273,7 +3273,7 @@ dependencies = [ [[package]] name = "gix-chunk" version = "0.4.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "thiserror 2.0.9", ] @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "gix-command" version = "0.4.1" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-path 0.10.14", @@ -3307,7 +3307,7 @@ dependencies = [ [[package]] name = "gix-commitgraph" version = "0.26.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-chunk 0.4.11", @@ -3321,7 +3321,7 @@ dependencies = [ [[package]] name = "gix-config" version = "0.43.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-config-value", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "gix-config-value" version = "0.14.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "gix-credentials" version = "0.27.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-command", @@ -3382,7 +3382,7 @@ dependencies = [ [[package]] name = "gix-date" version = "0.9.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "itoa 1.0.11", @@ -3394,7 +3394,7 @@ dependencies = [ [[package]] name = "gix-diff" version = "0.50.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-attributes 0.24.0", @@ -3417,7 +3417,7 @@ dependencies = [ [[package]] name = "gix-dir" version = "0.12.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-discover 0.38.0", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gix-discover" version = "0.38.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "dunce", @@ -3482,7 +3482,7 @@ dependencies = [ [[package]] name = "gix-features" version = "0.40.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bytes", "crc32fast", @@ -3504,7 +3504,7 @@ dependencies = [ [[package]] name = "gix-filter" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "encoding_rs", @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "gix-fs" version = "0.13.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "fastrand", "gix-features 0.40.0", @@ -3557,7 +3557,7 @@ dependencies = [ [[package]] name = "gix-glob" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3579,7 +3579,7 @@ dependencies = [ [[package]] name = "gix-hash" version = "0.16.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "faster-hex", "serde", @@ -3600,7 +3600,7 @@ dependencies = [ [[package]] name = "gix-hashtable" version = "0.7.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-hash 0.16.0", "hashbrown 0.14.5", @@ -3623,7 +3623,7 @@ dependencies = [ [[package]] name = "gix-ignore" version = "0.13.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-glob 0.18.0", @@ -3664,7 +3664,7 @@ dependencies = [ [[package]] name = "gix-index" version = "0.38.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3703,7 +3703,7 @@ dependencies = [ [[package]] name = "gix-lock" version = "16.0.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-tempfile 16.0.0", "gix-utils 0.1.14", @@ -3713,7 +3713,7 @@ dependencies = [ [[package]] name = "gix-mailmap" version = "0.25.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-actor 0.33.2", @@ -3725,7 +3725,7 @@ dependencies = [ [[package]] name = "gix-merge" version = "0.3.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-command", @@ -3749,7 +3749,7 @@ dependencies = [ [[package]] name = "gix-negotiate" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "gix-commitgraph 0.26.0", @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "gix-object" version = "0.47.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-actor 0.33.2", @@ -3804,7 +3804,7 @@ dependencies = [ [[package]] name = "gix-odb" version = "0.67.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "arc-swap", "gix-date 0.9.3", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "gix-pack" version = "0.57.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "clru", "gix-chunk 0.4.11", @@ -3846,7 +3846,7 @@ dependencies = [ [[package]] name = "gix-packetline" version = "0.18.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "faster-hex", @@ -3857,7 +3857,7 @@ dependencies = [ [[package]] name = "gix-packetline-blocking" version = "0.18.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "faster-hex", @@ -3881,7 +3881,7 @@ dependencies = [ [[package]] name = "gix-path" version = "0.10.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-trace 0.1.12", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "gix-pathspec" version = "0.9.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3907,7 +3907,7 @@ dependencies = [ [[package]] name = "gix-prompt" version = "0.9.1" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-command", "gix-config-value", @@ -3919,7 +3919,7 @@ dependencies = [ [[package]] name = "gix-protocol" version = "0.48.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-credentials", @@ -3956,7 +3956,7 @@ dependencies = [ [[package]] name = "gix-quote" version = "0.4.15" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-utils 0.1.14", @@ -3988,7 +3988,7 @@ dependencies = [ [[package]] name = "gix-ref" version = "0.50.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-actor 0.33.2", "gix-features 0.40.0", @@ -4009,7 +4009,7 @@ dependencies = [ [[package]] name = "gix-refspec" version = "0.28.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-hash 0.16.0", @@ -4022,7 +4022,7 @@ dependencies = [ [[package]] name = "gix-revision" version = "0.32.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "bstr", @@ -4055,7 +4055,7 @@ dependencies = [ [[package]] name = "gix-revwalk" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "gix-commitgraph 0.26.0", "gix-date 0.9.3", @@ -4081,7 +4081,7 @@ dependencies = [ [[package]] name = "gix-sec" version = "0.10.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "gix-path 0.10.14", @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "gix-shallow" version = "0.2.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-hash 0.16.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "gix-status" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "filetime", @@ -4127,7 +4127,7 @@ dependencies = [ [[package]] name = "gix-submodule" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-config", @@ -4156,7 +4156,7 @@ dependencies = [ [[package]] name = "gix-tempfile" version = "16.0.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "dashmap", "gix-fs 0.13.0", @@ -4201,7 +4201,7 @@ checksum = "04bdde120c29f1fc23a24d3e115aeeea3d60d8e65bab92cc5f9d90d9302eb952" [[package]] name = "gix-trace" version = "0.1.12" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "tracing-core", ] @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "gix-transport" version = "0.45.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "base64 0.22.1", "bstr", @@ -4245,7 +4245,7 @@ dependencies = [ [[package]] name = "gix-traverse" version = "0.44.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bitflags 2.6.0", "gix-commitgraph 0.26.0", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "gix-url" version = "0.29.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-features 0.40.0", @@ -4285,7 +4285,7 @@ dependencies = [ [[package]] name = "gix-utils" version = "0.1.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "fastrand", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "gix-validate" version = "0.9.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "thiserror 2.0.9", @@ -4333,7 +4333,7 @@ dependencies = [ [[package]] name = "gix-worktree" version = "0.39.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-attributes 0.24.0", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "gix-worktree-state" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e#0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e" +source = "git+https://github.com/GitoxideLabs/gitoxide?rev=7255a5fc0aa790b54e3176e8ecf066457acd9eef#7255a5fc0aa790b54e3176e8ecf066457acd9eef" dependencies = [ "bstr", "gix-features 0.40.0", @@ -5398,7 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -5612,9 +5612,9 @@ checksum = "a05b5d0594e0cb1ad8cee3373018d2b84e25905dc75b2468114cc9a8e86cfc20" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", diff --git a/Cargo.toml b/Cargo.toml index fde7f3c143..64f543af9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.dependencies] bstr = "1.11.1" # Add the `tracing` or `tracing-detail` features to see more of gitoxide in the logs. Useful to see which programs it invokes. -gix = { git = "https://github.com/GitoxideLabs/gitoxide", rev = "0bf1d5b9f0b0971b9f25a8e44b7818e37c78d68e", default-features = false, features = [ +gix = { git = "https://github.com/GitoxideLabs/gitoxide", rev = "7255a5fc0aa790b54e3176e8ecf066457acd9eef", default-features = false, features = [ ] } gix-testtools = "0.15.0" insta = "1.41.1" diff --git a/crates/but-cli/src/args.rs b/crates/but-cli/src/args.rs index 8bbdf48a44..98898eaeb8 100644 --- a/crates/but-cli/src/args.rs +++ b/crates/but-cli/src/args.rs @@ -45,6 +45,9 @@ pub enum Subcommands { }, /// List all uncommitted working tree changes. Status { + /// Also compute unified diffs for each tree-change. + #[clap(long, short = 'c', default_value_t = 3)] + context_lines: u32, /// Also compute unified diffs for each tree-change. #[clap(long, short = 'd')] unified_diff: bool, diff --git a/crates/but-cli/src/command/diff.rs b/crates/but-cli/src/command/diff.rs index 8c16354652..d059235612 100644 --- a/crates/but-cli/src/command/diff.rs +++ b/crates/but-cli/src/command/diff.rs @@ -1,4 +1,4 @@ -use crate::command::{debug_print, project_from_path, project_repo}; +use crate::command::{UI_CONTEXT_LINES, debug_print, project_from_path, project_repo}; use gix::bstr::BString; use itertools::Itertools; use std::path::Path; @@ -18,18 +18,18 @@ pub fn commit_changes( but_core::diff::commit_changes(&repo, previous_commit.map(Into::into), commit.into())?; if unified_diff { - debug_print(unified_diff_for_changes(&repo, changes)?) + debug_print(unified_diff_for_changes(&repo, changes, UI_CONTEXT_LINES)?) } else { debug_print(changes) } } -pub fn status(current_dir: &Path, unified_diff: bool) -> anyhow::Result<()> { +pub fn status(current_dir: &Path, unified_diff: bool, context_lines: u32) -> anyhow::Result<()> { let repo = project_repo(current_dir)?; let worktree = but_core::diff::worktree_changes(&repo)?; if unified_diff { debug_print(( - unified_diff_for_changes(&repo, worktree.changes)?, + unified_diff_for_changes(&repo, worktree.changes, context_lines)?, worktree.ignored_changes, )) } else { @@ -57,12 +57,13 @@ pub fn locks(current_dir: &Path) -> anyhow::Result<()> { fn unified_diff_for_changes( repo: &gix::Repository, changes: Vec, + context_lines: u32, ) -> anyhow::Result> { changes .into_iter() .map(|tree_change| { tree_change - .unified_diff(repo, 3) + .unified_diff(repo, context_lines) .map(|diff| (tree_change, diff)) }) .collect::, _>>() diff --git a/crates/but-cli/src/command/mod.rs b/crates/but-cli/src/command/mod.rs index a06c45e3f5..4973324a74 100644 --- a/crates/but-cli/src/command/mod.rs +++ b/crates/but-cli/src/command/mod.rs @@ -3,6 +3,8 @@ use gitbutler_project::Project; use gix::bstr::BString; use std::path::Path; +const UI_CONTEXT_LINES: u32 = 3; + pub fn project_from_path(path: &Path) -> anyhow::Result { Project::from_path(path) } diff --git a/crates/but-cli/src/main.rs b/crates/but-cli/src/main.rs index c9511c4145..2d4ffa5d5f 100644 --- a/crates/but-cli/src/main.rs +++ b/crates/but-cli/src/main.rs @@ -37,9 +37,10 @@ fn main() -> Result<()> { ) } args::Subcommands::HunkDependency => command::diff::locks(&args.current_dir), - args::Subcommands::Status { unified_diff } => { - command::diff::status(&args.current_dir, *unified_diff) - } + args::Subcommands::Status { + unified_diff, + context_lines, + } => command::diff::status(&args.current_dir, *unified_diff, *context_lines), args::Subcommands::CommitChanges { unified_diff, current_commit, diff --git a/crates/but-core/src/diff/mod.rs b/crates/but-core/src/diff/mod.rs index 8e945b12df..99fb55dc45 100644 --- a/crates/but-core/src/diff/mod.rs +++ b/crates/but-core/src/diff/mod.rs @@ -1,8 +1,130 @@ pub(crate) mod commit; + +use bstr::{BStr, ByteSlice}; pub use commit::commit_changes; mod worktree; +use crate::{ChangeState, ModeFlags, TreeChange, TreeStatus, TreeStatusKind}; pub use worktree::worktree_changes; /// conversion functions for use in the UI pub mod ui; + +impl TreeStatus { + /// Learn what kind of status this is, useful if only this information is needed. + pub fn kind(&self) -> TreeStatusKind { + match self { + TreeStatus::Addition { .. } => TreeStatusKind::Addition, + TreeStatus::Deletion { .. } => TreeStatusKind::Deletion, + TreeStatus::Modification { .. } => TreeStatusKind::Modification, + TreeStatus::Rename { .. } => TreeStatusKind::Rename, + } + } + + /// Return the state in which the change is currently. May be `None` if there is no current state after a deletion. + pub fn state(&self) -> Option { + match self { + TreeStatus::Addition { state, .. } + | TreeStatus::Rename { state, .. } + | TreeStatus::Modification { state, .. } => Some(*state), + TreeStatus::Deletion { .. } => None, + } + } + + /// Return the previous state that the change originated from. May be `None` if there is no previous state, for instance after an addition. + /// Also provide the path from which the state was possibly obtained. + pub fn previous_state_and_path(&self) -> Option<(ChangeState, Option<&BStr>)> { + match self { + TreeStatus::Addition { .. } => None, + TreeStatus::Rename { + previous_state, + previous_path, + .. + } => Some((*previous_state, Some(previous_path.as_bstr()))), + TreeStatus::Modification { previous_state, .. } + | TreeStatus::Deletion { previous_state, .. } => Some((*previous_state, None)), + } + } +} + +impl TreeChange { + /// Return the path at which this directory entry was previously located, if it was renamed. + pub fn previous_path(&self) -> Option<&BStr> { + match &self.status { + TreeStatus::Addition { .. } + | TreeStatus::Deletion { .. } + | TreeStatus::Modification { .. } => None, + TreeStatus::Rename { previous_path, .. } => Some(previous_path.as_ref()), + } + } +} + +impl ModeFlags { + fn calculate(old: &ChangeState, new: &ChangeState) -> Option { + Self::calculate_inner(old.kind, new.kind) + } + + fn calculate_inner( + old: gix::object::tree::EntryKind, + new: gix::object::tree::EntryKind, + ) -> Option { + use gix::object::tree::EntryKind as E; + Some(match (old, new) { + (E::Blob, E::BlobExecutable) => ModeFlags::ExecutableBitAdded, + (E::BlobExecutable, E::Blob) => ModeFlags::ExecutableBitRemoved, + (E::Blob | E::BlobExecutable, E::Link) => ModeFlags::TypeChangeFileToLink, + (E::Link, E::Blob | E::BlobExecutable) => ModeFlags::TypeChangeLinkToFile, + (a, b) if a != b => ModeFlags::TypeChange, + _ => return None, + }) + } +} + +#[cfg(test)] +mod tests { + mod flags { + use crate::ModeFlags; + use gix::objs::tree::EntryKind; + + #[test] + fn calculate() { + for ((old, new), expected) in [ + ((EntryKind::Blob, EntryKind::Blob), None), + ( + (EntryKind::Blob, EntryKind::BlobExecutable), + Some(ModeFlags::ExecutableBitAdded), + ), + ( + (EntryKind::BlobExecutable, EntryKind::Blob), + Some(ModeFlags::ExecutableBitRemoved), + ), + ( + (EntryKind::BlobExecutable, EntryKind::Link), + Some(ModeFlags::TypeChangeFileToLink), + ), + ( + (EntryKind::Blob, EntryKind::Link), + Some(ModeFlags::TypeChangeFileToLink), + ), + ( + (EntryKind::Link, EntryKind::BlobExecutable), + Some(ModeFlags::TypeChangeLinkToFile), + ), + ( + (EntryKind::Link, EntryKind::Blob), + Some(ModeFlags::TypeChangeLinkToFile), + ), + ( + (EntryKind::Commit, EntryKind::Blob), + Some(ModeFlags::TypeChange), + ), + ( + (EntryKind::Blob, EntryKind::Commit), + Some(ModeFlags::TypeChange), + ), + ] { + assert_eq!(ModeFlags::calculate_inner(old, new), expected); + } + } + } +} diff --git a/crates/but-core/src/diff/worktree.rs b/crates/but-core/src/diff/worktree.rs index c0ec7b35d4..d7aecfba83 100644 --- a/crates/but-core/src/diff/worktree.rs +++ b/crates/but-core/src/diff/worktree.rs @@ -2,16 +2,19 @@ use crate::{ ChangeState, IgnoredWorktreeChange, IgnoredWorktreeTreeChangeStatus, ModeFlags, TreeChange, TreeStatus, UnifiedDiff, WorktreeChanges, }; -use anyhow::Context; -use bstr::{BString, ByteSlice}; +use anyhow::{Context, bail}; +use bstr::{BStr, BString, ByteSlice}; use gix::dir::entry; use gix::dir::walk::EmissionMode; +use gix::filter::plumbing::pipeline::convert::ToGitOutcome; use gix::object::tree::EntryKind; use gix::status; use gix::status::index_worktree; use gix::status::index_worktree::RewriteSource; use gix::status::plumbing::index_as_worktree::{self, EntryStatus}; use gix::status::tree_index::TrackRenames; +use std::cmp::Ordering; +use std::io::Read; use std::path::PathBuf; /// Identify where a [`TreeChange`] is from. @@ -291,7 +294,9 @@ pub fn worktree_changes(repo: &gix::Repository) -> anyhow::Result { let previous_path: BString = source.rela_path().into(); @@ -317,7 +322,8 @@ pub fn worktree_changes(repo: &gix::Repository) -> anyhow::Result anyhow::Result; + let mut changes = Vec::::with_capacity(tmp.len()); + let (mut filter, index) = repo.filter_pipeline(None)?; + let mut path_check = gix::status::plumbing::SymlinkCheck::new( + repo.workdir().map(ToOwned::to_owned).context("non-bare")?, + ); for (_origin, change) in tmp { - if last_path.as_ref() == Some(&change.path) { + // At this point we know that the current `change` is the tree/index variant + // of a prior change between index/worktree. + if last_change + .as_ref() + .is_some_and(|last_change| cmp_prefer_overlapping(last_change, &change).is_eq()) + { + last_change = None; + // This is usually two modifications, but it's also possible that + // This one is a rename. In that case, we want the rename, combined + // with the current state pointing to the worktree, + // which we expect to be a modification + let index_wt_change = changes + .pop() + .expect("the reason we are here is the previous change"); + let change_path = change.path.clone(); + let tree_index_change = change; + let status = match merge_changes( + tree_index_change, + index_wt_change, + &mut filter, + &index, + &mut path_check, + )? { + None => IgnoredWorktreeTreeChangeStatus::TreeIndexWorktreeChangeIneffective, + Some(merged) => { + changes.push(merged); + IgnoredWorktreeTreeChangeStatus::TreeIndex + } + }; ignored_changes.push(IgnoredWorktreeChange { - path: change.path, - status: IgnoredWorktreeTreeChangeStatus::TreeIndex, + path: change_path, + status, }); continue; } - last_path = Some(change.path.clone()); changes.push(change); + last_change = changes.last(); } Ok(WorktreeChanges { @@ -431,6 +469,210 @@ pub fn worktree_changes(repo: &gix::Repository) -> anyhow::Result Ordering { + if a.path == b.path + || a.previous_path() == Some(b.path.as_bstr()) + || Some(a.path.as_bstr()) == b.previous_path() + { + Ordering::Equal + } else { + a.path.cmp(&b.path) + } +} + +/// Merge changes from tree/index into changes of `index_wt` and assure the merged result isn't a no-op, +/// which is when `None` is returned. +/// Note that this case is more expensive as we have to hash the worktree version to check for a no-op. +/// `diff_filter` is used to obtain hashes of worktree content. +fn merge_changes( + mut tree_index: TreeChange, + mut index_wt: TreeChange, + filter: &mut gix::filter::Pipeline<'_>, + index: &gix::index::State, + path_check: &mut gix::status::plumbing::SymlinkCheck, +) -> anyhow::Result> { + let merged = match (&mut tree_index.status, &mut index_wt.status) { + (TreeStatus::Modification { .. }, TreeStatus::Addition { .. }) + | (TreeStatus::Deletion { .. }, TreeStatus::Deletion { .. }) + | (TreeStatus::Deletion { .. }, TreeStatus::Rename { .. }) + | (TreeStatus::Deletion { .. }, TreeStatus::Modification { .. }) + | ( + TreeStatus::Deletion { .. }, + TreeStatus::Addition { + is_untracked: false, + .. + }, + ) + | (TreeStatus::Addition { .. }, TreeStatus::Addition { .. }) => { + bail!( + "BUG: entered unreachable code with tree_index_change = {:?} and index_wt_change = {:?}", + tree_index.status.kind(), + index_wt.status.kind() + ); + } + ( + TreeStatus::Addition { + is_untracked, + state, + }, + TreeStatus::Modification { + state: state_wt, .. + }, + ) => { + *is_untracked = true; + *state = *state_wt; + return Ok(Some(tree_index)); + } + (TreeStatus::Addition { .. }, TreeStatus::Deletion { .. }) => { + // keep the most recent known state, which is from the index. + return Ok(Some(index_wt)); + } + ( + TreeStatus::Addition { state, .. }, + TreeStatus::Rename { + previous_state: ps_wt, + .. + }, + ) => { + // This is conflicting actually, and a little bit unclear what commiting this will do. + // Pretend the added file (in index) is the one that was deleted, hence the rename. + *ps_wt = *state; + // Can't be no-op as this is a rename + return Ok(Some(index_wt)); + } + ( + TreeStatus::Deletion { previous_state, .. }, + TreeStatus::Addition { + is_untracked: true, + state, + }, + ) => { + index_wt.status = TreeStatus::Modification { + previous_state: *previous_state, + state: *state, + flags: None, + }; + index_wt + } + ( + TreeStatus::Modification { previous_state, .. }, + TreeStatus::Modification { + previous_state: ps_wt, + .. + }, + ) => { + *ps_wt = *previous_state; + index_wt + } + (TreeStatus::Modification { .. }, TreeStatus::Deletion { .. }) => { + return Ok(Some(index_wt)); + } + ( + TreeStatus::Modification { previous_state, .. }, + TreeStatus::Rename { + previous_state: ps_wt, + .. + }, + ) => { + *ps_wt = *previous_state; + index_wt + } + (TreeStatus::Rename { .. }, TreeStatus::Modification { .. }) => { + todo!("rename - mod") + } + (TreeStatus::Rename { .. }, TreeStatus::Rename { .. }) => { + todo!("rename - rename") + } + (TreeStatus::Rename { .. }, TreeStatus::Deletion { .. }) => { + todo!("rename - del") + } + (TreeStatus::Rename { .. }, TreeStatus::Addition { .. }) => { + todo!("rename-add") + } + }; + + let current = id_or_hash_from_worktree( + merged.status.state(), + merged.path.as_bstr(), + filter, + index, + path_check, + )?; + let (prev_state, prev_path) = merged + .status + .previous_state_and_path() + .map(|(a, b)| (Some(a), b)) + .unwrap_or((None, None)); + let previous = id_or_hash_from_worktree( + prev_state, + prev_path.unwrap_or(merged.path.as_bstr()), + filter, + index, + path_check, + )?; + Ok(if current == previous { + None + } else { + Some(merged) + }) +} + +// TODO(gix): worktree-status already can do hashing and to-git conversions while dealing with links, but it's not exposed. +// Make this easier in Gix. +/// Produces hashes for comparing states, or produce a hash from what's on disk if no hash is available. +/// Note that null-hash will be returned if no directory entry is available. +fn id_or_hash_from_worktree( + change: Option, + rela_path: &BStr, + filter: &mut gix::filter::Pipeline<'_>, + index: &gix::index::State, + path_check: &mut gix::status::plumbing::SymlinkCheck, +) -> anyhow::Result { + let Some(change) = change else { + return Ok(index.object_hash().null()); + }; + if !change.id.is_null() { + return Ok(change.id); + } + + let path = path_check.verified_path_allow_nonexisting(rela_path)?; + let md = path.symlink_metadata()?; + let repo = filter.repo; + let id = if md.is_file() { + let to_git = filter.convert_to_git( + std::fs::File::open(path)?, + // TODO(gix): definitely use `ToCompoents` here to avoid these conversions. + // Whatever you do, it's never right, so must be abstract. + &gix::path::try_from_bstr(rela_path)?, + index, + )?; + match to_git { + ToGitOutcome::Unchanged(mut stream) => gix::objs::compute_stream_hash( + repo.object_hash(), + gix::object::Kind::Blob, + &mut stream, + md.len(), + &mut gix::progress::Discard, + &gix::interrupt::IS_INTERRUPTED, + )?, + ToGitOutcome::Process(mut stream) => { + let mut buf = repo.empty_reusable_buffer(); + stream.read_to_end(&mut buf)?; + gix::objs::compute_hash(repo.object_hash(), gix::object::Kind::Blob, &buf) + } + ToGitOutcome::Buffer(buf) => { + gix::objs::compute_hash(repo.object_hash(), gix::object::Kind::Blob, buf) + } + } + } else if md.is_symlink() { + let bytes = gix::path::os_string_into_bstring(std::fs::read_link(path)?.into())?; + gix::objs::compute_hash(repo.object_hash(), gix::object::Kind::Blob, &bytes) + } else { + bail!("Cannot hash directory entries that aren't files or symlinks"); + }; + Ok(id) +} + fn into_tree_entry_kind(mode: gix::index::entry::Mode) -> anyhow::Result { Ok(mode .to_tree_entry_mode() @@ -478,44 +720,70 @@ impl TreeChange { &self, repo: &gix::Repository, context_lines: u32, + ) -> anyhow::Result { + let mut diff_filter = crate::unified_diff::filter_from_state( + repo, + self.status.state(), + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + )?; + self.unified_diff_with_filter(repo, context_lines, &mut diff_filter) + } + + /// Like [`Self::unified_diff()`], but uses `diff_filter` to control the content used for the diff. + pub fn unified_diff_with_filter( + &self, + repo: &gix::Repository, + context_lines: u32, + diff_filter: &mut gix::diff::blob::Platform, ) -> anyhow::Result { match &self.status { - TreeStatus::Deletion { previous_state } => UnifiedDiff::compute( + TreeStatus::Deletion { previous_state } => UnifiedDiff::compute_with_filter( repo, self.path.as_bstr(), None, None, *previous_state, context_lines, + diff_filter, ), TreeStatus::Addition { state, is_untracked: _, - } => UnifiedDiff::compute(repo, self.path.as_bstr(), None, *state, None, context_lines), + } => UnifiedDiff::compute_with_filter( + repo, + self.path.as_bstr(), + None, + *state, + None, + context_lines, + diff_filter, + ), TreeStatus::Modification { state, previous_state, flags: _, - } => UnifiedDiff::compute( + } => UnifiedDiff::compute_with_filter( repo, self.path.as_bstr(), None, *state, *previous_state, context_lines, + diff_filter, ), TreeStatus::Rename { previous_path, previous_state, state, flags: _, - } => UnifiedDiff::compute( + } => UnifiedDiff::compute_with_filter( repo, self.path.as_bstr(), Some(previous_path.as_bstr()), *state, *previous_state, context_lines, + diff_filter, ), } } diff --git a/crates/but-core/src/lib.rs b/crates/but-core/src/lib.rs index 04eade51ea..b071301ce5 100644 --- a/crates/but-core/src/lib.rs +++ b/crates/but-core/src/lib.rs @@ -41,7 +41,7 @@ //! - A list of patches in unified diff format, with easily accessible line number information. It isn't baked into the patch string itself. //! -use bstr::{BStr, BString}; +use bstr::BString; use gix::object::tree::EntryKind; use gix::refs::FullNameRef; use serde::Serialize; @@ -200,18 +200,6 @@ pub struct TreeChange { pub status: TreeStatus, } -impl TreeChange { - /// Return the path at which this directory entry was previously located, if it was renamed. - pub fn previous_path(&self) -> Option<&BStr> { - match &self.status { - TreeStatus::Addition { .. } - | TreeStatus::Deletion { .. } - | TreeStatus::Modification { .. } => None, - TreeStatus::Rename { previous_path, .. } => Some(previous_path.as_ref()), - } - } -} - /// Specifically defines a [`TreeChange`]. #[derive(Debug, Clone)] pub enum TreeStatus { @@ -293,6 +281,9 @@ pub enum IgnoredWorktreeTreeChangeStatus { Conflict, /// A change in the `.git/index` that was overruled by a change to the same path in the *worktree*. TreeIndex, + /// A tree-index change was effectively undone by an index-worktree change. Thus, the version in the worktree + /// is the same as what Git is currently tracking. + TreeIndexWorktreeChangeIneffective, } /// A way to indicate that a path in the index isn't suitable for committing and needs to be dealt with. @@ -326,85 +317,3 @@ pub enum ModeFlags { TypeChangeLinkToFile, TypeChange, } - -impl ModeFlags { - fn calculate(old: &ChangeState, new: &ChangeState) -> Option { - Self::calculate_inner(old.kind, new.kind) - } - - fn calculate_inner( - old: gix::object::tree::EntryKind, - new: gix::object::tree::EntryKind, - ) -> Option { - use gix::object::tree::EntryKind as E; - Some(match (old, new) { - (E::Blob, E::BlobExecutable) => ModeFlags::ExecutableBitAdded, - (E::BlobExecutable, E::Blob) => ModeFlags::ExecutableBitRemoved, - (E::Blob | E::BlobExecutable, E::Link) => ModeFlags::TypeChangeFileToLink, - (E::Link, E::Blob | E::BlobExecutable) => ModeFlags::TypeChangeLinkToFile, - (a, b) if a != b => ModeFlags::TypeChange, - _ => return None, - }) - } -} - -impl TreeStatus { - /// Learn what kind of status this is, useful if only this information is needed. - pub fn kind(&self) -> TreeStatusKind { - match self { - TreeStatus::Addition { .. } => TreeStatusKind::Addition, - TreeStatus::Deletion { .. } => TreeStatusKind::Deletion, - TreeStatus::Modification { .. } => TreeStatusKind::Modification, - TreeStatus::Rename { .. } => TreeStatusKind::Rename, - } - } -} - -#[cfg(test)] -mod tests { - mod flags { - use crate::ModeFlags; - use gix::objs::tree::EntryKind; - - #[test] - fn calculate() { - for ((old, new), expected) in [ - ((EntryKind::Blob, EntryKind::Blob), None), - ( - (EntryKind::Blob, EntryKind::BlobExecutable), - Some(ModeFlags::ExecutableBitAdded), - ), - ( - (EntryKind::BlobExecutable, EntryKind::Blob), - Some(ModeFlags::ExecutableBitRemoved), - ), - ( - (EntryKind::BlobExecutable, EntryKind::Link), - Some(ModeFlags::TypeChangeFileToLink), - ), - ( - (EntryKind::Blob, EntryKind::Link), - Some(ModeFlags::TypeChangeFileToLink), - ), - ( - (EntryKind::Link, EntryKind::BlobExecutable), - Some(ModeFlags::TypeChangeLinkToFile), - ), - ( - (EntryKind::Link, EntryKind::Blob), - Some(ModeFlags::TypeChangeLinkToFile), - ), - ( - (EntryKind::Commit, EntryKind::Blob), - Some(ModeFlags::TypeChange), - ), - ( - (EntryKind::Blob, EntryKind::Commit), - Some(ModeFlags::TypeChange), - ), - ] { - assert_eq!(ModeFlags::calculate_inner(old, new), expected); - } - } - } -} diff --git a/crates/but-core/src/unified_diff.rs b/crates/but-core/src/unified_diff.rs index aac0f71c8b..bf19c8a7ff 100644 --- a/crates/but-core/src/unified_diff.rs +++ b/crates/but-core/src/unified_diff.rs @@ -35,6 +35,13 @@ pub struct DiffHunk { } impl UnifiedDiff { + /// Determine how resources are converted to their form used for diffing. + /// + /// `ToGit` means that we want to see manifests of `git-lfs` for instance, or generally the result of 'clean' filters. + /// Doing so also yields a more 'universal' form that is certainly helpful when displaying it in a user interface. + pub const CONVERSION_MODE: gix::diff::blob::pipeline::Mode = + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent; + /// Given a worktree-relative `path` to a resource already tracked in Git, or one that is currently untracked, /// create a patch in unified diff format that turns `previous_state` into `current_state`, with the given /// amount of `context_lines`. @@ -61,18 +68,34 @@ impl UnifiedDiff { context_lines: u32, ) -> anyhow::Result { let current_state = current_state.into(); - let previous_state = previous_state.into(); - let mut cache = repo.diff_resource_cache( - gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, - gix::diff::blob::pipeline::WorktreeRoots { - old_root: None, - new_root: current_state - .filter(|state| state.id.is_null()) - .and_then(|_| repo.workdir().map(ToOwned::to_owned)), - }, - )?; + let mut cache = filter_from_state(repo, current_state, Self::CONVERSION_MODE)?; + Self::compute_with_filter( + repo, + path, + previous_path, + current_state, + previous_state, + context_lines, + &mut cache, + ) + } - cache.set_resource( + /// Similar to [`Self::compute()`], but uses `diff_filter` to obtain the diff content. + /// + /// This is useful to assure it's clear which content is ultimately used for the produced uni-diff, + /// as `filter` is responsible for that. + pub fn compute_with_filter( + repo: &gix::Repository, + path: &BStr, + previous_path: Option<&BStr>, + current_state: impl Into>, + previous_state: impl Into>, + context_lines: u32, + diff_filter: &mut gix::diff::blob::Platform, + ) -> anyhow::Result { + let current_state = current_state.into(); + let previous_state = previous_state.into(); + diff_filter.set_resource( current_state.map_or(repo.object_hash().null(), |state| state.id), current_state.map_or_else( || { @@ -86,7 +109,7 @@ impl UnifiedDiff { ResourceKind::NewOrDestination, repo, )?; - cache.set_resource( + diff_filter.set_resource( previous_state.map_or(repo.object_hash().null(), |state| state.id), previous_state.map_or_else( || { @@ -101,7 +124,7 @@ impl UnifiedDiff { repo, )?; - let prep = cache.prepare_diff()?; + let prep = diff_filter.prepare_diff()?; Ok(match prep.operation { Operation::InternalDiff { algorithm } => { #[derive(Default)] @@ -166,7 +189,7 @@ impl UnifiedDiff { Data::Binary { size } => Some(size), } } - let (old, new) = cache + let (old, new) = diff_filter .resources() .expect("prepare would have failed if a resource is missing"); let size = size_for_data(old.data) @@ -184,3 +207,20 @@ impl UnifiedDiff { }) } } + +/// Produce a filter from `repo` and `state` using `mode` that is able to perform diffs of `state`. +pub fn filter_from_state( + repo: &gix::Repository, + state: Option, + filter_mode: gix::diff::blob::pipeline::Mode, +) -> anyhow::Result { + Ok(repo.diff_resource_cache( + filter_mode, + gix::diff::blob::pipeline::WorktreeRoots { + old_root: None, + new_root: state + .filter(|state| state.id.is_null()) + .and_then(|_| repo.workdir().map(ToOwned::to_owned)), + }, + )?) +} diff --git a/crates/but-core/tests/core/diff/ui.rs b/crates/but-core/tests/core/diff/ui.rs index b7a1045e5d..4394b50107 100644 --- a/crates/but-core/tests/core/diff/ui.rs +++ b/crates/but-core/tests/core/diff/ui.rs @@ -284,13 +284,17 @@ fn worktree_changes() -> anyhow::Result<()> { 101 ], "status": { - "type": "Addition", + "type": "Modification", "subject": { + "previousState": { + "id": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "kind": "Blob" + }, "state": { "id": "0000000000000000000000000000000000000000", "kind": "Blob" }, - "isUntracked": true + "flags": null } } }, diff --git a/crates/but-core/tests/core/diff/worktree_changes.rs b/crates/but-core/tests/core/diff/worktree_changes.rs index 40947a667e..a6ae85c843 100644 --- a/crates/but-core/tests/core/diff/worktree_changes.rs +++ b/crates/but-core/tests/core/diff/worktree_changes.rs @@ -1107,7 +1107,7 @@ fn renamed_in_worktree() -> Result<()> { kind: Blob, }, state: ChangeState { - id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), + id: Sha1(0000000000000000000000000000000000000000), kind: Blob, }, flags: None, @@ -1143,7 +1143,7 @@ fn renamed_in_worktree_with_executable_bit() -> Result<()> { kind: BlobExecutable, }, state: ChangeState { - id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), + id: Sha1(0000000000000000000000000000000000000000), kind: BlobExecutable, }, flags: None, @@ -1164,8 +1164,8 @@ fn renamed_in_worktree_with_executable_bit() -> Result<()> { } #[test] -fn modified_in_index_and_workingtree() -> Result<()> { - let repo = repo("modified-in-index-and-worktree")?; +fn modified_in_index_and_worktree_mod_mod() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-mod-mod")?; let actual = diff::worktree_changes(&repo)?; insta::assert_debug_snapshot!(actual, @r#" WorktreeChanges { @@ -1174,7 +1174,7 @@ fn modified_in_index_and_workingtree() -> Result<()> { path: "dual-modified", status: Modification { previous_state: ChangeState { - id: Sha1(8ea0713f9d637081cc0098035465c365c0c32949), + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), kind: Blob, }, state: ChangeState { @@ -1194,27 +1194,380 @@ fn modified_in_index_and_workingtree() -> Result<()> { } "#); - let actual = unified_diffs(actual, &repo)?; + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,1 +1,3 @@ + initial + +change + +second-change + "); + + let repo = crate::diff::worktree_changes::repo("modified-in-index-and-worktree-mod-mod-noop")?; + insta::assert_debug_snapshot!(diff::worktree_changes(&repo)?, @r#" + WorktreeChanges { + changes: [], + ignored_changes: [ + IgnoredWorktreeChange { + path: "dual-modified", + status: TreeIndexWorktreeChangeIneffective, + }, + ], + } + "#); + + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_mod_mod_symlink() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-mod-mod-symlink")?; + let actual = diff::worktree_changes(&repo)?; insta::assert_debug_snapshot!(actual, @r#" - [ - Patch { - hunks: [ - DiffHunk { - old_start: 1, - old_lines: 2, - new_start: 1, - new_lines: 3, - diff: "@@ -1,2 +1,3 @@\n initial\n change\n+second-change\n", + WorktreeChanges { + changes: [ + TreeChange { + path: "link", + status: Modification { + previous_state: ChangeState { + id: Sha1(db2424764122191b9f3bc032bbf4b09e1b31d301), + kind: Link, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Link, + }, + flags: None, }, - ], - }, - ] + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "link", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,1 +1,1 @@ + -nonexisting-initial + +nonexisting-wt-change + "); + + let repo = + crate::diff::worktree_changes::repo("modified-in-index-and-worktree-mod-mod-symlink-noop")?; + insta::assert_debug_snapshot!(diff::worktree_changes(&repo)?, @r#" + WorktreeChanges { + changes: [], + ignored_changes: [ + IgnoredWorktreeChange { + path: "link", + status: TreeIndexWorktreeChangeIneffective, + }, + ], + } + "#); + + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_add_mod() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-add-mod")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file", + status: Addition { + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + is_untracked: true, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } "#); - let [UnifiedDiff::Patch { hunks }] = &actual[..] else { + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,0 +1,2 @@ + +initial + +wt-change + "); + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_add_del() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-add-del")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file", + status: Deletion { + previous_state: ChangeState { + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), + kind: Blob, + }, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,1 +1,0 @@ + -initial + "); + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_del_add() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-del-add")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file", + status: Modification { + previous_state: ChangeState { + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,1 +1,2 @@ + initial + +wt-changed + "); + + let repo = crate::diff::worktree_changes::repo("modified-in-index-and-worktree-del-add-noop")?; + insta::assert_debug_snapshot!(diff::worktree_changes(&repo)?, @r#" + WorktreeChanges { + changes: [], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndexWorktreeChangeIneffective, + }, + ], + } + "#); + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_mod_del() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-mod-del")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file", + status: Deletion { + previous_state: ChangeState { + id: Sha1(983aca27780b0a4bcb122a7d603aad940e694d3d), + kind: Blob, + }, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { unreachable!("need hunks") }; // newlines at the end should work. insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,2 +1,0 @@ + -initial + -index + "); + Ok(()) +} + +#[test] +#[ignore = "TBD later"] +fn modified_in_index_and_worktree_rename_mod() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-rename-mod")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "dual-modified", + status: Modification { + previous_state: ChangeState { + id: Sha1(8ea0713f9d637081cc0098035465c365c0c32949), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "dual-modified", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,2 +1,3 @@ + initial + change + +second-change + "); + Ok(()) +} + +#[test] +#[ignore = "TBD later"] +fn modified_in_index_and_worktree_rename_rename() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-rename-rename")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "dual-modified", + status: Modification { + previous_state: ChangeState { + id: Sha1(8ea0713f9d637081cc0098035465c365c0c32949), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "dual-modified", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,2 +1,3 @@ + initial + change + +second-change + "); + Ok(()) +} + +#[test] +#[ignore = "TBD later"] +fn modified_in_index_and_worktree_rename_del() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-rename-del")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "dual-modified", + status: Modification { + previous_state: ChangeState { + id: Sha1(8ea0713f9d637081cc0098035465c365c0c32949), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "dual-modified", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" @@ -1,2 +1,3 @@ initial change @@ -1223,6 +1576,136 @@ fn modified_in_index_and_workingtree() -> Result<()> { Ok(()) } +#[test] +fn modified_in_index_and_worktree_mod_rename() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-mod-rename")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file-renamed-in-wt", + status: Rename { + previous_path: "file", + previous_state: ChangeState { + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,1 +1,3 @@ + initial + +index + +wt-change + "); + Ok(()) +} + +#[test] +#[ignore = "TBD later"] +fn modified_in_index_and_worktree_rename_add() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-rename-add")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file-renamed-in-index", + status: Rename { + previous_path: "file", + previous_state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + state: ChangeState { + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file-renamed-in-index", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + insta::assert_snapshot!(hunks[0].diff, @r" + @@ -1,0 +1,1 @@ + +initial + "); + Ok(()) +} + +#[test] +fn modified_in_index_and_worktree_add_rename() -> Result<()> { + let repo = repo("modified-in-index-and-worktree-add-rename")?; + let actual = diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(actual, @r#" + WorktreeChanges { + changes: [ + TreeChange { + path: "file-renamed-in-wt", + status: Rename { + previous_path: "file", + previous_state: ChangeState { + id: Sha1(e79c5e8f964493290a409888d5413a737e8e5dd5), + kind: Blob, + }, + state: ChangeState { + id: Sha1(0000000000000000000000000000000000000000), + kind: Blob, + }, + flags: None, + }, + }, + ], + ignored_changes: [ + IgnoredWorktreeChange { + path: "file", + status: TreeIndex, + }, + ], + } + "#); + + let [UnifiedDiff::Patch { ref hunks }] = unified_diffs(actual, &repo)?[..] else { + unreachable!("need hunks") + }; + assert_eq!( + hunks.len(), + 0, + "the file didn't actually change, it's just renamed" + ); + Ok(()) +} + fn unified_diffs( worktree: WorktreeChanges, repo: &gix::Repository, diff --git a/crates/but-core/tests/fixtures/worktree-changes.sh b/crates/but-core/tests/fixtures/worktree-changes.sh index c9fe615d22..7b53fcd0ad 100755 --- a/crates/but-core/tests/fixtures/worktree-changes.sh +++ b/crates/but-core/tests/fixtures/worktree-changes.sh @@ -101,14 +101,115 @@ git init renamed-in-worktree-with-executable-bit mv to-be-renamed new-name ) -git init modified-in-index-and-worktree -(cd modified-in-index-and-worktree +git init modified-in-index-and-worktree-mod-mod +(cd modified-in-index-and-worktree-mod-mod echo initial >dual-modified git add . && git commit -m "init" echo change >>dual-modified && git add dual-modified echo second-change >>dual-modified ) +git init modified-in-index-and-worktree-mod-mod-noop +(cd modified-in-index-and-worktree-mod-mod-noop + echo initial >dual-modified + git add . && git commit -m "init" + echo change >>dual-modified && git add dual-modified + echo initial >dual-modified +) + +git init modified-in-index-and-worktree-mod-mod-symlink +(cd modified-in-index-and-worktree-mod-mod-symlink + ln -s nonexisting-initial link + git add . && git commit -m "init" + rm link && ln -s nonexisting-index link && git add . + rm link && ln -s nonexisting-wt-change link +) + +git init modified-in-index-and-worktree-mod-mod-symlink-noop +(cd modified-in-index-and-worktree-mod-mod-symlink-noop + ln -s nonexisting-initial link + git add . && git commit -m "init" + rm link && ln -s nonexisting-index link && git add . + rm link && ln -s nonexisting-initial link +) + +git init modified-in-index-and-worktree-add-mod +(cd modified-in-index-and-worktree-add-mod + echo initial >file + git add . + echo wt-change >>file +) + +git init modified-in-index-and-worktree-add-del +(cd modified-in-index-and-worktree-add-del + echo initial >file + git add . + rm file +) + +git init modified-in-index-and-worktree-del-add +(cd modified-in-index-and-worktree-del-add + echo initial >file && git add . && git commit -m "init" + git rm file + echo $'initial\nwt-changed' >file +) + +git init modified-in-index-and-worktree-del-add-noop +(cd modified-in-index-and-worktree-del-add-noop + echo initial >file && git add . && git commit -m "init" + git rm file + echo initial >file +) + +git init modified-in-index-and-worktree-mod-del +(cd modified-in-index-and-worktree-mod-del + echo initial >file && git add . && git commit -m "init" + echo index >>file && git add . + rm file +) + +git init modified-in-index-and-worktree-rename-mod +(cd modified-in-index-and-worktree-rename-mod + echo initial >file && git add . && git commit -m "init" + git mv file file-renamed + echo wt-change >>file-renamed +) + +git init modified-in-index-and-worktree-rename-rename +(cd modified-in-index-and-worktree-rename-rename + echo initial >file && git add . && git commit -m "init" + git mv file file-renamed-in-index + mv file-renamed-in-index file-renamed-in-wt +) + +git init modified-in-index-and-worktree-rename-del +(cd modified-in-index-and-worktree-rename-del + echo initial >file && git add . && git commit -m "init" + git mv file file-renamed-in-index + rm file-renamed-in-index +) + +git init modified-in-index-and-worktree-mod-rename +(cd modified-in-index-and-worktree-mod-rename + echo initial >file && git add . && git commit -m "init" + echo index >>file && git add . + echo wt-change >>file + mv file file-renamed-in-wt +) + +git init modified-in-index-and-worktree-rename-add +(cd modified-in-index-and-worktree-rename-add + echo initial >file && git add . && git commit -m "init" + git mv file file-renamed-in-index + echo $'initial\nwt-change' >file +) + +git init modified-in-index-and-worktree-add-rename +(cd modified-in-index-and-worktree-add-rename + echo initial >file && git add . + mv file file-renamed-in-wt +) + git init submodule-added-unborn (cd submodule-added-unborn git submodule add ../modified-in-index submodule diff --git a/crates/but-workspace/src/commit_engine/tree.rs b/crates/but-workspace/src/commit_engine/tree.rs index 7ed7667cdb..d1d47c6332 100644 --- a/crates/but-workspace/src/commit_engine/tree.rs +++ b/crates/but-workspace/src/commit_engine/tree.rs @@ -2,8 +2,7 @@ use crate::commit_engine::{Destination, DiffSpec, HunkHeader, MoveSourceCommit, use anyhow::{Context, bail}; use bstr::{BString, ByteSlice}; use but_core::{RepositoryExt, UnifiedDiff}; -use gix::filter::plumbing::driver::apply::{Delay, MaybeDelayed}; -use gix::filter::plumbing::pipeline::convert::{ToGitOutcome, ToWorktreeOutcome}; +use gix::filter::plumbing::pipeline::convert::ToGitOutcome; use gix::merge::tree::TreatAsUnresolved; use gix::object::tree::EntryKind; use gix::prelude::ObjectIdExt; @@ -191,6 +190,8 @@ fn apply_worktree_changes<'repo>( base_tree_editor.remove(previous_path)?; } let rela_path = change_request.path.as_bstr(); + // TODO: this is wrong, we know that the diffs were created from the version stored in Git, + // useful for git-lfs I suppose. Fix this conversion so hunks line up. match pipeline.worktree_file_to_object(rela_path, &index)? { Some((id, kind, _fs_metadata)) => { base_tree_editor.upsert(rela_path, kind, id)?; @@ -209,7 +210,18 @@ fn apply_worktree_changes<'repo>( into_err_spec(possible_change, RejectionReason::NoEffectiveChanges); continue; }; - let UnifiedDiff::Patch { hunks } = worktree_change.unified_diff(repo, context_lines)? + let mut diff_filter = but_core::unified_diff::filter_from_state( + repo, + worktree_change.status.state(), + UnifiedDiff::CONVERSION_MODE, + )?; + debug_assert_eq!( + UnifiedDiff::CONVERSION_MODE, + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + "BUG: if this changes, the uses of worktree filters need a review" + ); + let UnifiedDiff::Patch { hunks } = + worktree_change.unified_diff_with_filter(repo, context_lines, &mut diff_filter)? else { into_err_spec(possible_change, RejectionReason::FileToLargeOrBinary); continue; @@ -259,36 +271,29 @@ fn apply_worktree_changes<'repo>( continue; }; - let worktree_base = if entry.mode().is_link() { + let worktree_base = if entry.mode().is_link() || entry.mode().is_blob() { entry.object()?.detach().data - } else if entry.mode().is_blob() { - let mut obj_in_git = entry.object()?; - - match pipeline.convert_to_worktree( - &obj_in_git.data, - base_rela_path, - Delay::Forbid, - )? { - ToWorktreeOutcome::Unchanged(_) => obj_in_git.detach().data, - ToWorktreeOutcome::Buffer(buf) => buf.to_owned(), - ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut stream)) => { - obj_in_git.data.clear(); - stream.read_to_end(&mut obj_in_git.data)?; - obj_in_git.detach().data - } - ToWorktreeOutcome::Process(MaybeDelayed::Delayed(_)) => { - unreachable!("We forbade that") - } - } } else { // defensive: assure file wasn't swapped with something we can't handle into_err_spec(possible_change, RejectionReason::UnsupportedTreeEntry); continue; }; - // TODO(performance): find a byte-line buffered reader so it doesn't have to be all in memory. current_worktree.clear(); - std::fs::File::open(path)?.read_to_end(&mut current_worktree)?; + let to_git = pipeline.convert_to_git( + std::fs::File::open(path)?, + &gix::path::from_bstr(base_rela_path), + &index, + )?; + match to_git { + ToGitOutcome::Unchanged(mut file) => { + file.read_to_end(&mut current_worktree)?; + } + ToGitOutcome::Process(mut stream) => { + stream.read_to_end(&mut current_worktree)?; + } + ToGitOutcome::Buffer(buf) => current_worktree.extend_from_slice(buf), + }; let worktree_hunks: Vec = hunks.into_iter().map(Into::into).collect(); let mut worktree_base_cursor = 1; /* 1-based counting */ @@ -343,18 +348,7 @@ fn apply_worktree_changes<'repo>( base_with_patches.extend_from_slice(line); } - let slice_read = &mut base_with_patches.as_slice(); - let to_git = pipeline.convert_to_git( - slice_read, - gix::path::from_bstr(&change_request.path).as_ref(), - &index, - )?; - - let blob_with_selected_patches = match to_git { - ToGitOutcome::Unchanged(slice) => repo.write_blob(slice)?, - ToGitOutcome::Process(stream) => repo.write_blob_stream(stream)?, - ToGitOutcome::Buffer(buf) => repo.write_blob(buf)?, - }; + let blob_with_selected_patches = repo.write_blob(base_with_patches.as_slice())?; base_tree_editor.upsert( change_request.path.as_bstr(), current_entry_kind, diff --git a/crates/but-workspace/src/discard/file.rs b/crates/but-workspace/src/discard/file.rs new file mode 100644 index 0000000000..afdafc2a24 --- /dev/null +++ b/crates/but-workspace/src/discard/file.rs @@ -0,0 +1,334 @@ +use crate::discard::file::index::mark_entry_for_deletion; +use crate::discard::locked_resource_at; +use anyhow::{Context, bail}; +use bstr::{BStr, BString, ByteSlice, ByteVec}; +use but_core::ChangeState; +use gix::filter::plumbing::driver::apply::Delay; +use gix::object::tree::EntryKind; +use gix::prelude::ObjectIdExt; +use std::path::Path; + +pub enum RestoreMode { + /// Assume the resource to be restored doesn't exist as it was deleted. + Deleted, + /// A similar resource is in its place that needs to be updated. + Update, +} + +/// Restore `state` by writing it into the worktree of `repo`, possibly re-adding or updating the +/// `index` with it so that it matches the worktree. +pub fn restore_state_to_worktree( + pipeline: &mut gix::filter::Pipeline<'_>, + index: &mut gix::index::State, + rela_path: &BStr, + state: ChangeState, + mode: RestoreMode, + path_check: &mut gix::status::plumbing::SymlinkCheck, + num_sorted_entries: &mut usize, +) -> anyhow::Result<()> { + if state.id.is_null() { + bail!( + "Change to discard at '{rela_path}' didn't have a last-known tracked state - this is a bug" + ); + } + + let mut update_index = |md| -> anyhow::Result<()> { + crate::commit_engine::index::upsert_index_entry( + index, + rela_path, + &md, + state.id, + state.kind.into(), + gix::index::entry::Flags::UPDATE, + num_sorted_entries, + )?; + Ok(()) + }; + + let repo = pipeline.repo; + let wt_root = path_check.inner.root().to_owned(); + let file_path = path_check.verified_path_allow_nonexisting(rela_path)?; + match state.kind { + EntryKind::Blob | EntryKind::BlobExecutable => { + let mut dest_lock_file = locked_resource_at(wt_root, &file_path, state.kind)?; + let obj_in_git = state.id.attach(repo).object()?; + let mut stream = + pipeline.convert_to_worktree(&obj_in_git.data, rela_path, Delay::Forbid)?; + std::io::copy(&mut stream, &mut dest_lock_file)?; + + let (file_path, maybe_file) = match dest_lock_file.commit() { + Ok(res) => res, + Err(err) => { + if err.error.kind() == std::io::ErrorKind::IsADirectory { + // It's OK to remove everything that's in the way. + // Alternatives to this is to let it be handled by the stack. + std::fs::remove_dir_all(err.instance.resource_path())?; + err.instance.commit()? + } else { + return Err(err.into()); + } + } + }; + update_index(match maybe_file { + None => gix::index::fs::Metadata::from_path_no_follow(&file_path)?, + Some(file) => gix::index::fs::Metadata::from_file(&file)?, + })?; + } + EntryKind::Link => { + let link_path = file_path; + if let RestoreMode::Update = mode { + std::fs::remove_file(&link_path)?; + } + let link_target = state.id.attach(repo).object()?; + let link_target = gix::path::from_bstr(link_target.data.as_bstr()); + if let Err(err) = gix::fs::symlink::create(&link_target, &link_path) { + // When directories are replaced, the user could undo everything. Then + // it's a matter of order if *we* have already created the directory content. + if err.kind() != std::io::ErrorKind::AlreadyExists + || !link_path.symlink_metadata()?.is_symlink() + { + return Err(err.into()); + } + } + update_index(gix::index::fs::Metadata::from_path_no_follow(&link_path)?)?; + } + EntryKind::Commit => { + if let RestoreMode::Update = mode { + // TODO(gix): actual checkout/reset functionality - it will be fine to support that fully. + // Since `git2` doesn't support filters, it will save us some trouble to just use Git for that. + let submodule_repo_dir = &file_path; + let out = std::process::Command::from( + gix::command::prepare(format!( + "git reset --hard {id} && git clean -fxd", + id = state.id + )) + .with_shell(), + ) + .current_dir(submodule_repo_dir) + .output()?; + if !out.status.success() { + bail!( + "Could not reset submodule at '{sm_dir}' to commit {id}: {err}", + sm_dir = submodule_repo_dir.display(), + id = state.id, + err = out.stderr.as_bstr() + ); + } + } else { + let sm_repo = repo + .submodules()? + .into_iter() + .flatten() + .find_map(|sm| { + let is_active = sm.is_active().ok()?; + is_active.then(|| -> anyhow::Result<_> { + Ok( + if sm.path().ok().is_some_and(|sm_path| sm_path == rela_path) { + sm.open()? + } else { + None + }, + ) + }) + }) + .transpose()? + .flatten(); + match sm_repo { + None => { + // A directory is what git creates with `git restore` even if the thing to restore is a submodule. + // We are trying to be better than that if we find a submodule, hoping that this is what users expect. + // We do that as baseline as there is no need to fail here. + } + Some(repo) => { + // We will only restore the submodule if there is a local clone already available, to avoid any network + // activity that would likely happen during an actual clone. + // Thus, all we have to do is to check out the submodule. + // TODO(gix): find a way to deal with nested submodules - they should also be checked out which + // isn't done by `gitoxide`, but probably should be an option there. + checkout_repo_worktree(&wt_root, repo)?; + } + } + std::fs::create_dir(&file_path).or_else(|err| { + if err.kind() == std::io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(err) + } + })?; + } + update_index(gix::index::fs::Metadata::from_path_no_follow(&file_path)?)?; + } + EntryKind::Tree => { + mark_entry_for_deletion(index, rela_path, *num_sorted_entries); + let checkout_destination = file_path; + let mut sub_index = repo.index_from_tree(&state.id)?; + let mut opts = + repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?; + // there may be situations where files already exist in that spot, likely because we put them + // there earlier as part of a sweeping 'discard'. Still, try not to mess with the user. + opts.overwrite_existing = false; + if !checkout_destination.exists() { + std::fs::create_dir(&checkout_destination)?; + opts.destination_is_initially_empty = true; + } + // TODO(gix): make it possible to have this checkout submodules as well. + let out = gix::worktree::state::checkout( + &mut sub_index, + checkout_destination.as_ref(), + repo.clone().objects.into_arc()?, + &gix::progress::Discard, + &gix::progress::Discard, + &gix::interrupt::IS_INTERRUPTED, + opts, + )?; + tracing::debug!(directory = ?checkout_destination, outcome = ?out, "directory checkout result"); + + let (entries, path_storage) = sub_index.into_parts().0.into_entries(); + let mut rela_path = with_trailing_slash(rela_path); + let prefix_len = rela_path.len(); + for entry in entries { + let partial_rela_path = entry.path_in(&path_storage); + rela_path.extend_from_slice(partial_rela_path); + + if index.entry_by_path(rela_path.as_bstr()).is_none() { + index.dangerously_push_entry( + entry.stat, + entry.id, + entry.flags | gix::index::entry::Flags::UPDATE, + entry.mode, + rela_path.as_bstr(), + ); + } + rela_path.truncate(prefix_len); + } + // These might be re-visited later if the user was able to add individual deletions in a directory. + // Sort to make index-lookups work. + index.sort_entries(); + *num_sorted_entries = index.entries().len(); + } + }; + Ok(()) +} + +fn with_trailing_slash(rela_path: &BStr) -> BString { + if rela_path.ends_with_str(b"/") { + return rela_path.to_owned(); + } + let mut buf = rela_path.to_owned(); + buf.push(b'/'); + buf +} + +fn checkout_repo_worktree( + parent_worktree_dir: &Path, + mut repo: gix::Repository, +) -> anyhow::Result<()> { + // No need to cache anything, it's just single-use for the most part. + repo.object_cache_size(0); + let mut index = repo.index_from_tree(&repo.head_tree_id_or_empty()?)?; + if index.entries().is_empty() { + // The worktree directory is created later, so we don't have to deal with it here. + return Ok(()); + } + for entry in index.entries_mut().iter_mut().filter(|e| { + e.mode + .contains(gix::index::entry::Mode::DIR | gix::index::entry::Mode::COMMIT) + }) { + entry.flags.insert(gix::index::entry::Flags::SKIP_WORKTREE); + } + + let mut opts = + repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?; + opts.destination_is_initially_empty = true; + opts.keep_going = true; + + let checkout_destination = repo.workdir().context("non-bare repository")?.to_owned(); + if !checkout_destination.exists() { + std::fs::create_dir(&checkout_destination)?; + } + let sm_repo_dir = gix::path::relativize_with_prefix( + repo.path().strip_prefix(parent_worktree_dir)?, + checkout_destination.strip_prefix(parent_worktree_dir)?, + ) + .into_owned(); + let out = gix::worktree::state::checkout( + &mut index, + checkout_destination.clone(), + repo, + &gix::progress::Discard, + &gix::progress::Discard, + &gix::interrupt::IS_INTERRUPTED, + opts, + )?; + + let mut buf = BString::from("gitdir: "); + buf.extend_from_slice(&gix::path::os_string_into_bstring(sm_repo_dir.into())?); + buf.push_byte(b'\n'); + std::fs::write(checkout_destination.join(".git"), &buf)?; + + tracing::debug!(directory = ?checkout_destination, outcome = ?out, "submodule checkout result"); + Ok(()) +} + +/// Remove files present at `rela_path`, restore the index at that place, if possible, +/// and if necessary, checkout everything that this revealed. +/// This is required when handling renames. +pub fn purge_and_restore_from_head_tree( + index: &mut gix::index::State, + rela_path: &BStr, + path_check: &mut gix::status::plumbing::SymlinkCheck, + num_sorted_entries: usize, +) -> anyhow::Result<()> { + if let Some(range) = index.entry_range(with_trailing_slash(rela_path).as_bstr()) { + #[allow(clippy::indexing_slicing)] + for entry in &mut index.entries_mut()[range] { + entry.flags.insert(gix::index::entry::Flags::REMOVE); + } + } else { + mark_entry_for_deletion(index, rela_path, num_sorted_entries); + } + + // TODO(motivational test): restore what was there in the the index, and then on disk by checkout. + let path = path_check.verified_path(&gix::path::from_bstr(rela_path))?; + if !path.is_dir() { + // Should always exist, this is why it's a rename in the first place. + std::fs::remove_file(path).or_else(|err| { + if matches!( + err.kind(), + std::io::ErrorKind::NotADirectory | std::io::ErrorKind::NotFound + ) { + Ok(()) + } else { + Err(err) + } + })?; + } else { + bail!("BUG: it's unclear how this case would occur, get a test for it") + } + Ok(()) +} + +pub(super) mod index { + use bstr::BStr; + use gix::index::entry::Stage; + + pub fn mark_entry_for_deletion( + state: &mut gix::index::State, + rela_path: &BStr, + num_sorted_entries: usize, + ) { + for stage in [Stage::Unconflicted, Stage::Base, Stage::Ours, Stage::Theirs] { + // TODO(perf): `gix` should offer a way to get the *first* index by path so the + // binary search doesn't have to be repeated. + let Some(entry_idx) = + state.entry_index_by_path_and_stage_bounded(rela_path, stage, num_sorted_entries) + else { + continue; + }; + #[allow(clippy::indexing_slicing)] + state.entries_mut()[entry_idx] + .flags + .insert(gix::index::entry::Flags::REMOVE); + } + } +} diff --git a/crates/but-workspace/src/discard/function.rs b/crates/but-workspace/src/discard/function.rs index 8a416cbdf5..58757b5384 100644 --- a/crates/but-workspace/src/discard/function.rs +++ b/crates/but-workspace/src/discard/function.rs @@ -1,13 +1,7 @@ -use crate::discard::DiscardSpec; -use crate::discard::function::index::mark_entry_for_deletion; -use anyhow::{Context, bail}; -use bstr::{BStr, BString, ByteSlice, ByteVec}; +use crate::discard::{DiscardSpec, file}; +use anyhow::Context; +use bstr::ByteSlice; use but_core::{ChangeState, TreeStatus}; -use gix::filter::plumbing::driver::apply::Delay; -use gix::object::tree::EntryKind; -use gix::prelude::ObjectIdExt; -use std::borrow::Cow; -use std::path::{Path, PathBuf}; /// Discard the given `changes` in the worktree of `repo`. If a change could not be matched with an actual worktree change, for /// instance due to a race, that's not an error, instead it will be returned in the result Vec. @@ -53,7 +47,7 @@ pub fn discard_workspace_changes( .verified_path(&gix::path::from_bstr(wt_change.path.as_bstr()))?, )?; if !is_untracked { - index::mark_entry_for_deletion( + file::index::mark_entry_for_deletion( &mut index, wt_change.path.as_bstr(), initial_entries_len, @@ -62,7 +56,7 @@ pub fn discard_workspace_changes( if let Some(entry) = head_tree.lookup_entry(wt_change.path.split(|b| *b == b'/'))? { - restore_state_to_worktree( + file::restore_state_to_worktree( &mut pipeline, &mut index, wt_change.path.as_bstr(), @@ -70,30 +64,30 @@ pub fn discard_workspace_changes( id: entry.object_id(), kind: entry.mode().into(), }, - RestoreMode::Deleted, + file::RestoreMode::Deleted, &mut path_check, &mut initial_entries_len, )? } } TreeStatus::Deletion { previous_state } => { - restore_state_to_worktree( + file::restore_state_to_worktree( &mut pipeline, &mut index, wt_change.path.as_bstr(), previous_state, - RestoreMode::Deleted, + file::RestoreMode::Deleted, &mut path_check, &mut initial_entries_len, )?; } TreeStatus::Modification { previous_state, .. } => { - restore_state_to_worktree( + file::restore_state_to_worktree( &mut pipeline, &mut index, wt_change.path.as_bstr(), previous_state, - RestoreMode::Update, + file::RestoreMode::Update, &mut path_check, &mut initial_entries_len, )?; @@ -103,18 +97,16 @@ pub fn discard_workspace_changes( previous_state, .. } => { - restore_state_to_worktree( + file::restore_state_to_worktree( &mut pipeline, &mut index, previous_path.as_bstr(), previous_state, - RestoreMode::Deleted, + file::RestoreMode::Deleted, &mut path_check, &mut initial_entries_len, )?; - purge_and_restore_from_head_tree( - &head_tree, - &mut pipeline, + file::purge_and_restore_from_head_tree( &mut index, wt_change.path.as_bstr(), &mut path_check, @@ -141,369 +133,3 @@ pub fn discard_workspace_changes( } Ok(dropped) } - -enum RestoreMode { - /// Assume the resource to be restored doesn't exist as it was deleted. - Deleted, - /// A similar resource is in its place that needs to be updated. - Update, -} - -/// Restore `state` by writing it into the worktree of `repo`, possibly re-adding or updating the -/// `index` with it so that it matches the worktree. -fn restore_state_to_worktree( - pipeline: &mut gix::filter::Pipeline<'_>, - index: &mut gix::index::State, - rela_path: &BStr, - state: ChangeState, - mode: RestoreMode, - path_check: &mut gix::status::plumbing::SymlinkCheck, - num_sorted_entries: &mut usize, -) -> anyhow::Result<()> { - if state.id.is_null() { - bail!( - "Change to discard at '{rela_path}' didn't have a last-known tracked state - this is a bug" - ); - } - - let mut update_index = |md| -> anyhow::Result<()> { - crate::commit_engine::index::upsert_index_entry( - index, - rela_path, - &md, - state.id, - state.kind.into(), - gix::index::entry::Flags::UPDATE, - num_sorted_entries, - )?; - Ok(()) - }; - - let repo = pipeline.repo; - let wt_root = path_check.inner.root().to_owned(); - let file_path = path_check - .verified_path(&gix::path::from_bstr(rela_path)) - .map(Cow::Borrowed) - .or_else(|err| { - if err.kind() == std::io::ErrorKind::NotFound { - Ok(Cow::Owned(wt_root.join(gix::path::from_bstr(rela_path)))) - } else { - Err(err) - } - })?; - match state.kind { - EntryKind::Blob | EntryKind::BlobExecutable => { - let mut dest_lock_file = locked_resource_at(wt_root, &file_path, state.kind)?; - let obj_in_git = state.id.attach(repo).object()?; - let mut stream = - pipeline.convert_to_worktree(&obj_in_git.data, rela_path, Delay::Forbid)?; - std::io::copy(&mut stream, &mut dest_lock_file)?; - - let (file_path, maybe_file) = match dest_lock_file.commit() { - Ok(res) => res, - Err(err) => { - if err.error.kind() == std::io::ErrorKind::IsADirectory { - // It's OK to remove everything that's in the way. - // Alternatives to this is to let it be handled by the stack. - std::fs::remove_dir_all(err.instance.resource_path())?; - err.instance.commit()? - } else { - return Err(err.into()); - } - } - }; - update_index(match maybe_file { - None => gix::index::fs::Metadata::from_path_no_follow(&file_path)?, - Some(file) => gix::index::fs::Metadata::from_file(&file)?, - })?; - } - EntryKind::Link => { - let link_path = file_path; - if let RestoreMode::Update = mode { - std::fs::remove_file(&link_path)?; - } - let link_target = state.id.attach(repo).object()?; - let link_target = gix::path::from_bstr(link_target.data.as_bstr()); - if let Err(err) = gix::fs::symlink::create(&link_target, &link_path) { - // When directories are replaced, the user could undo everything. Then - // it's a matter of order if *we* have already created the directory content. - if err.kind() != std::io::ErrorKind::AlreadyExists - || !link_path.symlink_metadata()?.is_symlink() - { - return Err(err.into()); - } - } - update_index(gix::index::fs::Metadata::from_path_no_follow(&link_path)?)?; - } - EntryKind::Commit => { - if let RestoreMode::Update = mode { - // TODO(gix): actual checkout/reset functionality - it will be fine to support that fully. - // Since `git2` doesn't support filters, it will save us some trouble to just use Git for that. - let submodule_repo_dir = &file_path; - let out = std::process::Command::from( - gix::command::prepare(format!( - "git reset --hard {id} && git clean -fxd", - id = state.id - )) - .with_shell(), - ) - .current_dir(submodule_repo_dir) - .output()?; - if !out.status.success() { - bail!( - "Could not reset submodule at '{sm_dir}' to commit {id}: {err}", - sm_dir = submodule_repo_dir.display(), - id = state.id, - err = out.stderr.as_bstr() - ); - } - } else { - let sm_repo = repo - .submodules()? - .into_iter() - .flatten() - .find_map(|sm| { - let is_active = sm.is_active().ok()?; - is_active.then(|| -> anyhow::Result<_> { - Ok( - if sm.path().ok().is_some_and(|sm_path| sm_path == rela_path) { - sm.open()? - } else { - None - }, - ) - }) - }) - .transpose()? - .flatten(); - match sm_repo { - None => { - // A directory is what git creates with `git restore` even if the thing to restore is a submodule. - // We are trying to be better than that if we find a submodule, hoping that this is what users expect. - // We do that as baseline as there is no need to fail here. - } - Some(repo) => { - // We will only restore the submodule if there is a local clone already available, to avoid any network - // activity that would likely happen during an actual clone. - // Thus, all we have to do is to check out the submodule. - // TODO(gix): find a way to deal with nested submodules - they should also be checked out which - // isn't done by `gitoxide`, but probably should be an option there. - checkout_repo_worktree(&wt_root, repo)?; - } - } - std::fs::create_dir(&file_path).or_else(|err| { - if err.kind() == std::io::ErrorKind::AlreadyExists { - Ok(()) - } else { - Err(err) - } - })?; - } - update_index(gix::index::fs::Metadata::from_path_no_follow(&file_path)?)?; - } - EntryKind::Tree => { - mark_entry_for_deletion(index, rela_path, *num_sorted_entries); - let checkout_destination = file_path; - let mut sub_index = repo.index_from_tree(&state.id)?; - let mut opts = - repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?; - // there may be situations where files already exist in that spot, likely because we put them - // there earlier as part of a sweeping 'discard'. Still, try not to mess with the user. - opts.overwrite_existing = false; - if !checkout_destination.exists() { - std::fs::create_dir(&checkout_destination)?; - opts.destination_is_initially_empty = true; - } - // TODO(gix): make it possible to have this checkout submodules as well. - let out = gix::worktree::state::checkout( - &mut sub_index, - checkout_destination.as_ref(), - repo.clone().objects.into_arc()?, - &gix::progress::Discard, - &gix::progress::Discard, - &gix::interrupt::IS_INTERRUPTED, - opts, - )?; - tracing::debug!(directory = ?checkout_destination, outcome = ?out, "directory checkout result"); - - let (entries, path_storage) = sub_index.into_parts().0.into_entries(); - let mut rela_path = with_trailing_slash(rela_path); - let prefix_len = rela_path.len(); - for entry in entries { - let partial_rela_path = entry.path_in(&path_storage); - rela_path.extend_from_slice(partial_rela_path); - - if index.entry_by_path(rela_path.as_bstr()).is_none() { - index.dangerously_push_entry( - entry.stat, - entry.id, - entry.flags | gix::index::entry::Flags::UPDATE, - entry.mode, - rela_path.as_bstr(), - ); - } - rela_path.truncate(prefix_len); - } - // These might be re-visited later if the user was able to add individual deletions in a directory. - // Sort to make index-lookups work. - index.sort_entries(); - *num_sorted_entries = index.entries().len(); - } - }; - Ok(()) -} - -fn with_trailing_slash(rela_path: &BStr) -> BString { - if rela_path.ends_with_str(b"/") { - return rela_path.to_owned(); - } - let mut buf = rela_path.to_owned(); - buf.push(b'/'); - buf -} - -fn checkout_repo_worktree( - parent_worktree_dir: &Path, - mut repo: gix::Repository, -) -> anyhow::Result<()> { - // No need to cache anything, it's just single-use for the most part. - repo.object_cache_size(0); - let mut index = repo.index_from_tree(&repo.head_tree_id_or_empty()?)?; - if index.entries().is_empty() { - // The worktree directory is created later, so we don't have to deal with it here. - return Ok(()); - } - for entry in index.entries_mut().iter_mut().filter(|e| { - e.mode - .contains(gix::index::entry::Mode::DIR | gix::index::entry::Mode::COMMIT) - }) { - entry.flags.insert(gix::index::entry::Flags::SKIP_WORKTREE); - } - - let mut opts = - repo.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)?; - opts.destination_is_initially_empty = true; - opts.keep_going = true; - - let checkout_destination = repo.workdir().context("non-bare repository")?.to_owned(); - if !checkout_destination.exists() { - std::fs::create_dir(&checkout_destination)?; - } - let sm_repo_dir = gix::path::relativize_with_prefix( - repo.path().strip_prefix(parent_worktree_dir)?, - checkout_destination.strip_prefix(parent_worktree_dir)?, - ) - .into_owned(); - let out = gix::worktree::state::checkout( - &mut index, - checkout_destination.clone(), - repo, - &gix::progress::Discard, - &gix::progress::Discard, - &gix::interrupt::IS_INTERRUPTED, - opts, - )?; - - let mut buf = BString::from("gitdir: "); - buf.extend_from_slice(&gix::path::os_string_into_bstring(sm_repo_dir.into())?); - buf.push_byte(b'\n'); - std::fs::write(checkout_destination.join(".git"), &buf)?; - - tracing::debug!(directory = ?checkout_destination, outcome = ?out, "submodule checkout result"); - Ok(()) -} - -/// Remove files present at `rela_path`, restore the index at that place, if possible, -/// and if necessary, checkout everything that this revealed. -/// This is required when handling renames. -fn purge_and_restore_from_head_tree<'repo>( - _head_tree: &gix::Tree<'repo>, - _pipeline: &mut gix::filter::Pipeline<'repo>, - index: &mut gix::index::State, - rela_path: &BStr, - path_check: &mut gix::status::plumbing::SymlinkCheck, - num_sorted_entries: usize, -) -> anyhow::Result<()> { - if let Some(range) = index.entry_range(with_trailing_slash(rela_path).as_bstr()) { - #[allow(clippy::indexing_slicing)] - for entry in &mut index.entries_mut()[range] { - entry.flags.insert(gix::index::entry::Flags::REMOVE); - } - } else { - mark_entry_for_deletion(index, rela_path, num_sorted_entries); - } - - // TODO(motivational test): restore what was there in the the index, and then on disk by checkout. - let path = path_check.verified_path(&gix::path::from_bstr(rela_path))?; - if !path.is_dir() { - // Should always exist, this is why it's a rename in the first place. - std::fs::remove_file(path).or_else(|err| { - if matches!( - err.kind(), - std::io::ErrorKind::NotADirectory | std::io::ErrorKind::NotFound - ) { - Ok(()) - } else { - Err(err) - } - })?; - } else { - bail!("BUG: it's unclear how this case would occur, get a test for it") - } - Ok(()) -} - -mod index { - use bstr::BStr; - use gix::index::entry::Stage; - - pub fn mark_entry_for_deletion( - state: &mut gix::index::State, - rela_path: &BStr, - num_sorted_entries: usize, - ) { - for stage in [Stage::Unconflicted, Stage::Base, Stage::Ours, Stage::Theirs] { - // TODO(perf): `gix` should offer a way to get the *first* index by path so the - // binary search doesn't have to be repeated. - let Some(entry_idx) = - state.entry_index_by_path_and_stage_bounded(rela_path, stage, num_sorted_entries) - else { - continue; - }; - #[allow(clippy::indexing_slicing)] - state.entries_mut()[entry_idx] - .flags - .insert(gix::index::entry::Flags::REMOVE); - } - } -} - -#[cfg(unix)] -fn locked_resource_at( - root: PathBuf, - path: &Path, - kind: EntryKind, -) -> anyhow::Result { - use std::os::unix::fs::PermissionsExt; - Ok( - gix::lock::File::acquire_to_update_resource_with_permissions( - path, - gix::lock::acquire::Fail::Immediately, - Some(root), - || std::fs::Permissions::from_mode(kind as u32), - )?, - ) -} - -#[cfg(windows)] -fn locked_resource_at( - root: PathBuf, - path: &Path, - _kind: EntryKind, -) -> anyhow::Result { - Ok(gix::lock::File::acquire_to_update_resource( - path, - gix::lock::acquire::Fail::Immediately, - Some(root), - )?) -} diff --git a/crates/but-workspace/src/discard/mod.rs b/crates/but-workspace/src/discard/mod.rs index e5aa47a9f1..2162445274 100644 --- a/crates/but-workspace/src/discard/mod.rs +++ b/crates/but-workspace/src/discard/mod.rs @@ -1,7 +1,9 @@ //! Utility types related to discarding changes in the worktree. use crate::commit_engine::DiffSpec; +use gix::object::tree::EntryKind; use std::ops::Deref; +use std::path::{Path, PathBuf}; /// A specification of what should be discarded, either changes to the whole file, or a portion of it. /// Note that these must match an actual worktree change, but also may only partially match them if individual ranges are chosen @@ -34,3 +36,35 @@ pub mod ui { /// A specification of which worktree-change to discard. pub type DiscardSpec = crate::commit_engine::ui::DiffSpec; } + +mod file; + +#[cfg(unix)] +fn locked_resource_at( + root: PathBuf, + path: &Path, + kind: EntryKind, +) -> anyhow::Result { + use std::os::unix::fs::PermissionsExt; + Ok( + gix::lock::File::acquire_to_update_resource_with_permissions( + path, + gix::lock::acquire::Fail::Immediately, + Some(root), + || std::fs::Permissions::from_mode(kind as u32), + )?, + ) +} + +#[cfg(windows)] +fn locked_resource_at( + root: PathBuf, + path: &Path, + _kind: EntryKind, +) -> anyhow::Result { + Ok(gix::lock::File::acquire_to_update_resource( + path, + gix::lock::acquire::Fail::Immediately, + Some(root), + )?) +} diff --git a/crates/but-workspace/tests/workspace/discard.rs b/crates/but-workspace/tests/workspace/discard/file.rs similarity index 61% rename from crates/but-workspace/tests/workspace/discard.rs rename to crates/but-workspace/tests/workspace/discard/file.rs index 7f7c13bb3a..b81ecabd39 100644 --- a/crates/but-workspace/tests/workspace/discard.rs +++ b/crates/but-workspace/tests/workspace/discard/file.rs @@ -1,16 +1,16 @@ -use crate::discard::util::{file_to_spec, renamed_file_to_spec, worktree_changes_to_discard_specs}; use crate::utils::{visualize_index, writable_scenario, writable_scenario_slow}; use but_testsupport::{CommandExt, git, git_status, visualize_disk_tree_skip_dot_git}; use but_workspace::discard_workspace_changes; +use util::{file_to_spec, renamed_file_to_spec, worktree_changes_to_discard_specs}; #[test] fn all_file_types_from_unborn() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("unborn-untracked-all-file-types"); insta::assert_snapshot!(git_status(&repo)?, @r" - ?? link - ?? untracked - ?? untracked-exe - "); +?? link +?? untracked +?? untracked-exe +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); @@ -24,10 +24,10 @@ fn all_file_types_added_to_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("unborn-untracked-all-file-types"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - A link - A untracked - A untracked-exe - "); +A link +A untracked +A untracked-exe +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); @@ -42,39 +42,39 @@ fn all_file_types_added_to_index() -> anyhow::Result<()> { fn all_file_types_deleted_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("delete-all-file-types-valid-submodule"); insta::assert_snapshot!(git_status(&repo)?, @r" - D .gitmodules - D executable - D link - D submodule - "); +D .gitmodules +D executable +D link +D submodule +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:a047f81 embedded-repository - 100755:86daf54 executable - 100644:d95f3ad file-to-remain - 120000:b158162 link - 160000:a047f81 submodule - "); +100644:51f8807 .gitmodules +160000:a047f81 embedded-repository +100755:86daf54 executable +100644:d95f3ad file-to-remain +120000:b158162 link +160000:a047f81 submodule +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── .gitmodules:100644 - ├── embedded-repository:40755 - │ ├── .git:40755 - │ └── file:100644 - ├── executable:100755 - ├── file-to-remain:100644 - ├── link:120755 - └── submodule:40755 - ├── .git:100644 - └── file:100644 - "); +. +├── .git:40755 +├── .gitmodules:100644 +├── embedded-repository:40755 +│ ├── .git:40755 +│ └── file:100644 +├── executable:100755 +├── file-to-remain:100644 +├── link:120755 +└── submodule:40755 + ├── .git:100644 + └── file:100644 +"); Ok(()) } @@ -83,44 +83,44 @@ fn all_file_types_deleted_in_worktree() -> anyhow::Result<()> { fn replace_dir_with_file_discard_all_in_order_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("replace-dir-with-submodule-with-file"); insta::assert_snapshot!(git_status(&repo)?, @r" - D dir/executable - D dir/file-to-remain - D dir/link - D dir/submodule - ?? dir - "); + D dir/executable + D dir/file-to-remain + D dir/link + D dir/submodule +?? dir +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:566c83a .gitmodules - 100755:86daf54 dir/executable - 100644:d95f3ad dir/file-to-remain - 120000:b158162 dir/link - 160000:a047f81 dir/submodule - 160000:a047f81 embedded-repository - "); +100644:566c83a .gitmodules +100755:86daf54 dir/executable +100644:d95f3ad dir/file-to-remain +120000:b158162 dir/link +160000:a047f81 dir/submodule +160000:a047f81 embedded-repository +"); // Here we managed to check out the submodule as the order of worktree changes is `dir` first, // followed by all the individual items in the directory. One of these restores the submodule, which // starts out as empty directory. insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . +. +├── .git:40755 +├── .gitmodules:100644 +├── dir:40755 +│ ├── executable:100755 +│ ├── file-to-remain:100644 +│ ├── link:120755 +│ └── submodule:40755 +│ ├── .git:100644 +│ └── file:100644 +└── embedded-repository:40755 ├── .git:40755 - ├── .gitmodules:100644 - ├── dir:40755 - │ ├── executable:100755 - │ ├── file-to-remain:100644 - │ ├── link:120755 - │ └── submodule:40755 - │ ├── .git:100644 - │ └── file:100644 - └── embedded-repository:40755 - ├── .git:40755 - └── file:100644 - "); + └── file:100644 +"); Ok(()) } @@ -130,44 +130,44 @@ fn replace_dir_with_file_discard_all_in_order_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("replace-dir-with-submodule-with-file"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - A dir - D dir/executable - D dir/file-to-remain - D dir/link - D dir/submodule - "); +A dir +D dir/executable +D dir/file-to-remain +D dir/link +D dir/submodule +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:566c83a .gitmodules - 100755:86daf54 dir/executable - 100644:d95f3ad dir/file-to-remain - 120000:b158162 dir/link - 160000:a047f81 dir/submodule - 160000:a047f81 embedded-repository - "); +100644:566c83a .gitmodules +100755:86daf54 dir/executable +100644:d95f3ad dir/file-to-remain +120000:b158162 dir/link +160000:a047f81 dir/submodule +160000:a047f81 embedded-repository +"); // Here we managed to check out the submodule as the order of worktree changes is `dir` first, // followed by all the individual items in the directory. One of these restores the submodule, which // starts out as empty directory. insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . +. +├── .git:40755 +├── .gitmodules:100644 +├── dir:40755 +│ ├── executable:100755 +│ ├── file-to-remain:100644 +│ ├── link:120755 +│ └── submodule:40755 +│ ├── .git:100644 +│ └── file:100644 +└── embedded-repository:40755 ├── .git:40755 - ├── .gitmodules:100644 - ├── dir:40755 - │ ├── executable:100755 - │ ├── file-to-remain:100644 - │ ├── link:120755 - │ └── submodule:40755 - │ ├── .git:100644 - │ └── file:100644 - └── embedded-repository:40755 - ├── .git:40755 - └── file:100644 - "); + └── file:100644 +"); Ok(()) } @@ -176,40 +176,40 @@ fn replace_dir_with_file_discard_all_in_order_in_index() -> anyhow::Result<()> { fn replace_dir_with_file_discard_just_the_file_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("replace-dir-with-submodule-with-file"); insta::assert_snapshot!(git_status(&repo)?, @r" - D dir/executable - D dir/file-to-remain - D dir/link - D dir/submodule - ?? dir - "); + D dir/executable + D dir/file-to-remain + D dir/link + D dir/submodule +?? dir +"); let dropped = discard_workspace_changes(&repo, Some(file_to_spec("dir")))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:566c83a .gitmodules - 100755:86daf54 dir/executable - 100644:d95f3ad dir/file-to-remain - 120000:b158162 dir/link - 160000:a047f81 dir/submodule - 160000:a047f81 embedded-repository - "); +100644:566c83a .gitmodules +100755:86daf54 dir/executable +100644:d95f3ad dir/file-to-remain +120000:b158162 dir/link +160000:a047f81 dir/submodule +160000:a047f81 embedded-repository +"); // It's a known shortcoming that submodules aren't re-populated during checkout. insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . +. +├── .git:40755 +├── .gitmodules:100644 +├── dir:40755 +│ ├── executable:100755 +│ ├── file-to-remain:100644 +│ ├── link:120755 +│ └── submodule:40755 +└── embedded-repository:40755 ├── .git:40755 - ├── .gitmodules:100644 - ├── dir:40755 - │ ├── executable:100755 - │ ├── file-to-remain:100644 - │ ├── link:120755 - │ └── submodule:40755 - └── embedded-repository:40755 - ├── .git:40755 - └── file:100644 - "); + └── file:100644 +"); Ok(()) } @@ -219,10 +219,10 @@ fn conflicts_are_invisible() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("merge-with-two-branches-conflict"); insta::assert_snapshot!(git_status(&repo)?, @"UU file"); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:e69de29 file - 100644:e6c4914 file - 100644:e33f5e9 file - "); +100644:e69de29 file +100644:e6c4914 file +100644:e33f5e9 file +"); let dropped = discard_workspace_changes(&repo, Some(file_to_spec("file")))?; assert_eq!( @@ -234,15 +234,15 @@ fn conflicts_are_invisible() -> anyhow::Result<()> { // Nothing was changed insta::assert_snapshot!(git_status(&repo)?, @"UU file"); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:e69de29 file - 100644:e6c4914 file - 100644:e33f5e9 file - "); +100644:e69de29 file +100644:e6c4914 file +100644:e33f5e9 file +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - └── file:100644 - "); +. +├── .git:40755 +└── file:100644 +"); Ok(()) } @@ -252,40 +252,40 @@ fn replace_dir_with_file_discard_just_the_file_in_index() -> anyhow::Result<()> let (repo, _tmp) = writable_scenario("replace-dir-with-submodule-with-file"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - A dir - D dir/executable - D dir/file-to-remain - D dir/link - D dir/submodule - "); +A dir +D dir/executable +D dir/file-to-remain +D dir/link +D dir/submodule +"); let dropped = discard_workspace_changes(&repo, Some(file_to_spec("dir")))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:566c83a .gitmodules - 100755:86daf54 dir/executable - 100644:d95f3ad dir/file-to-remain - 120000:b158162 dir/link - 160000:a047f81 dir/submodule - 160000:a047f81 embedded-repository - "); +100644:566c83a .gitmodules +100755:86daf54 dir/executable +100644:d95f3ad dir/file-to-remain +120000:b158162 dir/link +160000:a047f81 dir/submodule +160000:a047f81 embedded-repository +"); // It's a known shortcoming that submodules aren't re-populated during checkout. insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . +. +├── .git:40755 +├── .gitmodules:100644 +├── dir:40755 +│ ├── executable:100755 +│ ├── file-to-remain:100644 +│ ├── link:120755 +│ └── submodule:40755 +└── embedded-repository:40755 ├── .git:40755 - ├── .gitmodules:100644 - ├── dir:40755 - │ ├── executable:100755 - │ ├── file-to-remain:100644 - │ ├── link:120755 - │ └── submodule:40755 - └── embedded-repository:40755 - ├── .git:40755 - └── file:100644 - "); + └── file:100644 +"); Ok(()) } @@ -294,37 +294,37 @@ fn replace_dir_with_file_discard_just_the_file_in_index() -> anyhow::Result<()> fn all_file_types_modified_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("all-file-types-changed"); insta::assert_snapshot!(git_status(&repo)?, @r" - M soon-executable - T soon-file-not-link - M soon-not-executable - "); +M soon-executable +T soon-file-not-link +M soon-not-executable +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── fifo-should-be-ignored:10644 - ├── soon-executable:100755 - ├── soon-file-not-link:100644 - └── soon-not-executable:100644 - "); +. +├── .git:40755 +├── fifo-should-be-ignored:10644 +├── soon-executable:100755 +├── soon-file-not-link:100644 +└── soon-not-executable:100644 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:d95f3ad soon-executable - 120000:c4c364c soon-file-not-link - 100755:86daf54 soon-not-executable - "); +100644:d95f3ad soon-executable +120000:c4c364c soon-file-not-link +100755:86daf54 soon-not-executable +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── fifo-should-be-ignored:10644 - ├── soon-executable:100644 - ├── soon-file-not-link:120755 - └── soon-not-executable:100755 - "); +. +├── .git:40755 +├── fifo-should-be-ignored:10644 +├── soon-executable:100644 +├── soon-file-not-link:120755 +└── soon-not-executable:100755 +"); Ok(()) } @@ -334,37 +334,37 @@ fn all_file_types_modified_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("all-file-types-changed"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - M soon-executable - T soon-file-not-link - M soon-not-executable - "); +M soon-executable +T soon-file-not-link +M soon-not-executable +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── fifo-should-be-ignored:10644 - ├── soon-executable:100755 - ├── soon-file-not-link:100644 - └── soon-not-executable:100644 - "); +. +├── .git:40755 +├── fifo-should-be-ignored:10644 +├── soon-executable:100755 +├── soon-file-not-link:100644 +└── soon-not-executable:100644 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:d95f3ad soon-executable - 120000:c4c364c soon-file-not-link - 100755:86daf54 soon-not-executable - "); +100644:d95f3ad soon-executable +120000:c4c364c soon-file-not-link +100755:86daf54 soon-not-executable +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── fifo-should-be-ignored:10644 - ├── soon-executable:100644 - ├── soon-file-not-link:120755 - └── soon-not-executable:100755 - "); +. +├── .git:40755 +├── fifo-should-be-ignored:10644 +├── soon-executable:100644 +├── soon-file-not-link:120755 +└── soon-not-executable:100755 +"); Ok(()) } @@ -373,27 +373,27 @@ fn all_file_types_modified_in_index() -> anyhow::Result<()> { fn modified_submodule_and_embedded_repo_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("modified-submodule-and-embedded-repo"); insta::assert_snapshot!(git_status(&repo)?, @r" - M embedded-repository - M submodule - "); +M embedded-repository +M submodule +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── .gitmodules:100644 - ├── embedded-repository:40755 - │ ├── .git:40755 - │ └── file:100644 - └── submodule:40755 - ├── .git:100644 - ├── file:100644 - └── untracked:100644 - "); +. +├── .git:40755 +├── .gitmodules:100644 +├── embedded-repository:40755 +│ ├── .git:40755 +│ └── file:100644 +└── submodule:40755 + ├── .git:100644 + ├── file:100644 + └── untracked:100644 +"); // The submdule has changed its state, but not what the parent-repository thinks about it as it wasn't added ot the index insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:a047f81 embedded-repository - 160000:a047f81 submodule - "); +100644:51f8807 .gitmodules +160000:a047f81 embedded-repository +160000:a047f81 submodule +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); @@ -403,22 +403,22 @@ fn modified_submodule_and_embedded_repo_in_worktree() -> anyhow::Result<()> { // However, the submodule itself is reset. insta::assert_snapshot!(git_status(&repo)?, @" M embedded-repository"); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:a047f81 embedded-repository - 160000:a047f81 submodule - "); +100644:51f8807 .gitmodules +160000:a047f81 embedded-repository +160000:a047f81 submodule +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── .gitmodules:100644 - ├── embedded-repository:40755 - │ ├── .git:40755 - │ └── file:100644 - └── submodule:40755 - ├── .git:100644 - └── file:100644 - "); +. +├── .git:40755 +├── .gitmodules:100644 +├── embedded-repository:40755 +│ ├── .git:40755 +│ └── file:100644 +└── submodule:40755 + ├── .git:100644 + └── file:100644 +"); Ok(()) } @@ -429,14 +429,14 @@ fn modified_submodule_and_embedded_repo_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("modified-submodule-and-embedded-repo"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - M embedded-repository - MM submodule - "); +M embedded-repository +MM submodule +"); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:6d5e0a5 embedded-repository - 160000:6d5e0a5 submodule - "); +100644:51f8807 .gitmodules +160000:6d5e0a5 embedded-repository +160000:6d5e0a5 submodule +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); @@ -444,10 +444,10 @@ fn modified_submodule_and_embedded_repo_in_index() -> anyhow::Result<()> { // `gix status` is able to see the 'embedded-repository' if it's in the index, and we can reset it as well. insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:a047f81 embedded-repository - 160000:a047f81 submodule - "); +100644:51f8807 .gitmodules +160000:a047f81 embedded-repository +160000:a047f81 submodule +"); Ok(()) } @@ -458,40 +458,40 @@ fn all_file_types_renamed_and_modified_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("all-file-types-renamed-and-modified"); // Git doesn't detect renames between index/worktree, but we do. insta::assert_snapshot!(git_status(&repo)?, @r" - D executable - D file - D link - ?? executable-renamed - ?? file-renamed - ?? link-renamed - "); + D executable + D file + D link +?? executable-renamed +?? file-renamed +?? link-renamed +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── executable-renamed:100755 - ├── fifo-should-be-ignored:10644 - ├── file-renamed:100644 - └── link-renamed:120755 - "); +. +├── .git:40755 +├── executable-renamed:100755 +├── fifo-should-be-ignored:10644 +├── file-renamed:100644 +└── link-renamed:120755 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:01e79c3 executable - 100644:3aac70f file - 120000:c4c364c link - "); +100755:01e79c3 executable +100644:3aac70f file +120000:c4c364c link +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── executable:100755 - ├── fifo-should-be-ignored:10644 - ├── file:100644 - └── link:120755 - "); +. +├── .git:40755 +├── executable:100755 +├── fifo-should-be-ignored:10644 +├── file:100644 +└── link:120755 +"); Ok(()) } @@ -501,43 +501,43 @@ fn all_file_types_renamed_modified_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("all-file-types-renamed-and-modified"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - R executable -> executable-renamed - R file -> file-renamed - D link - A link-renamed - "); +R executable -> executable-renamed +R file -> file-renamed +D link +A link-renamed +"); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:94ebaf9 executable-renamed - 100644:66f816c file-renamed - 120000:94e4e07 link-renamed - "); +100755:94ebaf9 executable-renamed +100644:66f816c file-renamed +120000:94e4e07 link-renamed +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── executable-renamed:100755 - ├── fifo-should-be-ignored:10644 - ├── file-renamed:100644 - └── link-renamed:120755 - "); +. +├── .git:40755 +├── executable-renamed:100755 +├── fifo-should-be-ignored:10644 +├── file-renamed:100644 +└── link-renamed:120755 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:01e79c3 executable - 100644:3aac70f file - 120000:c4c364c link - "); +100755:01e79c3 executable +100644:3aac70f file +120000:c4c364c link +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── executable:100755 - ├── fifo-should-be-ignored:10644 - ├── file:100644 - └── link:120755 - "); +. +├── .git:40755 +├── executable:100755 +├── fifo-should-be-ignored:10644 +├── file:100644 +└── link:120755 +"); Ok(()) } @@ -548,16 +548,16 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree() -> any // This is actually misleading as `file-to-be-dir` seems missing even though it's now // a directory. It's untracked-state isn't visible. insta::assert_snapshot!(git_status(&repo)?, @r" - D dir-to-be-file/content - D executable - D file - D file-to-be-dir - D link - D other-file - M to-be-overwritten - ?? dir-to-be-file - ?? link-renamed - "); + D dir-to-be-file/content + D executable + D file + D file-to-be-dir + D link + D other-file + M to-be-overwritten +?? dir-to-be-file +?? link-renamed +"); // `gix status` shows it like one would expect, but it can't detect renames here due to a shortcoming // inherited from Git. @@ -570,40 +570,40 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree() -> any // D other-file // M to-be-overwritten insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:100755 - ├── file-to-be-dir:40755 - │ └── file:100644 - ├── link-renamed:120755 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:100755 +├── file-to-be-dir:40755 +│ └── file:100644 +├── link-renamed:120755 +└── to-be-overwritten:100644 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:e69de29 dir-to-be-file/content - 100755:01e79c3 executable - 100644:3aac70f file - 100644:e69de29 file-to-be-dir - 120000:c4c364c link - 100644:dcefb7d other-file - 100644:e69de29 to-be-overwritten - "); +100644:e69de29 dir-to-be-file/content +100755:01e79c3 executable +100644:3aac70f file +100644:e69de29 file-to-be-dir +120000:c4c364c link +100644:dcefb7d other-file +100644:e69de29 to-be-overwritten +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:40755 - │ └── content:100644 - ├── executable:100755 - ├── file:100644 - ├── file-to-be-dir:100644 - ├── link:120755 - ├── other-file:100644 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:40755 +│ └── content:100644 +├── executable:100755 +├── file:100644 +├── file-to-be-dir:100644 +├── link:120755 +├── other-file:100644 +└── to-be-overwritten:100644 +"); Ok(()) } @@ -615,15 +615,15 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_index() -> anyhow // This is actually misleading as `file-to-be-dir` seems missing even though it's now // a directory. It's untracked-state isn't visible. insta::assert_snapshot!(git_status(&repo)?, @r" - R executable -> dir-to-be-file - D dir-to-be-file/content - D file-to-be-dir - R file -> file-to-be-dir/file - D link - A link-renamed - D other-file - M to-be-overwritten - "); +R executable -> dir-to-be-file +D dir-to-be-file/content +D file-to-be-dir +R file -> file-to-be-dir/file +D link +A link-renamed +D other-file +M to-be-overwritten +"); // `gix status` shows it like one would expect, but it can't detect renames here due to a shortcoming // inherited from Git. @@ -636,40 +636,40 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_index() -> anyhow // A link-renamed // D other-file insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:100755 - ├── file-to-be-dir:40755 - │ └── file:100644 - ├── link-renamed:120755 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:100755 +├── file-to-be-dir:40755 +│ └── file:100644 +├── link-renamed:120755 +└── to-be-overwritten:100644 +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:e69de29 dir-to-be-file/content - 100755:01e79c3 executable - 100644:3aac70f file - 100644:e69de29 file-to-be-dir - 120000:c4c364c link - 100644:dcefb7d other-file - 100644:e69de29 to-be-overwritten - "); +100644:e69de29 dir-to-be-file/content +100755:01e79c3 executable +100644:3aac70f file +100644:e69de29 file-to-be-dir +120000:c4c364c link +100644:dcefb7d other-file +100644:e69de29 to-be-overwritten +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:40755 - │ └── content:100644 - ├── executable:100755 - ├── file:100644 - ├── file-to-be-dir:100644 - ├── link:120755 - ├── other-file:100644 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:40755 +│ └── content:100644 +├── executable:100755 +├── file:100644 +├── file-to-be-dir:100644 +├── link:120755 +├── other-file:100644 +└── to-be-overwritten:100644 +"); Ok(()) } @@ -683,26 +683,26 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree_discard_ // This is actually misleading as `file-to-be-dir` seems missing even though it's now // a directory. It's untracked-state isn't visible. insta::assert_snapshot!(git_status(&repo)?, @r" - D dir-to-be-file/content - D executable - D file - D file-to-be-dir - D link - D other-file - M to-be-overwritten - ?? dir-to-be-file - ?? link-renamed - "); + D dir-to-be-file/content + D executable + D file + D file-to-be-dir + D link + D other-file + M to-be-overwritten +?? dir-to-be-file +?? link-renamed +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:100755 - ├── file-to-be-dir:40755 - │ └── file:100644 - ├── link-renamed:120755 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:100755 +├── file-to-be-dir:40755 +│ └── file:100644 +├── link-renamed:120755 +└── to-be-overwritten:100644 +"); let dropped = discard_workspace_changes( &repo, @@ -725,19 +725,19 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree_discard_ // nothing either. // This could be improved at some cost, so let's go with the two-step process for now. insta::assert_snapshot!(git_status(&repo)?, @r" - D dir-to-be-file/content - D file-to-be-dir - "); +D dir-to-be-file/content +D file-to-be-dir +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── executable:100755 - ├── file:100644 - ├── file-to-be-dir:40755 - ├── link:120755 - ├── other-file:100644 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── executable:100755 +├── file:100644 +├── file-to-be-dir:40755 +├── link:120755 +├── other-file:100644 +└── to-be-overwritten:100644 +"); // Try again with what remains, something that the user will likely do as well, not really knowing // why that is. @@ -747,26 +747,26 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree_discard_ insta::assert_snapshot!(git_status(&repo)?, @r""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:e69de29 dir-to-be-file/content - 100755:01e79c3 executable - 100644:3aac70f file - 100644:e69de29 file-to-be-dir - 120000:c4c364c link - 100644:dcefb7d other-file - 100644:e69de29 to-be-overwritten - "); +100644:e69de29 dir-to-be-file/content +100755:01e79c3 executable +100644:3aac70f file +100644:e69de29 file-to-be-dir +120000:c4c364c link +100644:dcefb7d other-file +100644:e69de29 to-be-overwritten +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── dir-to-be-file:40755 - │ └── content:100644 - ├── executable:100755 - ├── file:100644 - ├── file-to-be-dir:100644 - ├── link:120755 - ├── other-file:100644 - └── to-be-overwritten:100644 - "); +. +├── .git:40755 +├── dir-to-be-file:40755 +│ └── content:100644 +├── executable:100755 +├── file:100644 +├── file-to-be-dir:100644 +├── link:120755 +├── other-file:100644 +└── to-be-overwritten:100644 +"); Ok(()) } @@ -775,11 +775,11 @@ fn all_file_types_renamed_overwriting_existing_and_modified_in_worktree_discard_ fn folder_with_all_file_types_moved_upwards_in_worktree() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("move-directory-into-sibling-file"); insta::assert_snapshot!(git_status(&repo)?, @r" - D a/b/executable - D a/b/file - D a/b/link - D a/sibling - "); +D a/b/executable +D a/b/file +D a/b/link +D a/sibling +"); // For `gitoxide` this looks like this: // D a/sibling // R a/b/executable → a/sibling/executable @@ -787,14 +787,14 @@ fn folder_with_all_file_types_moved_upwards_in_worktree() -> anyhow::Result<()> // R a/b/link → a/sibling/link insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - └── a:40755 - └── sibling:40755 - ├── executable:100755 - ├── file:100644 - └── link:120755 - "); +. +├── .git:40755 +└── a:40755 + └── sibling:40755 + ├── executable:100755 + ├── file:100644 + └── link:120755 +"); // This naturally starts with `a/sibling` let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; @@ -802,21 +802,21 @@ fn folder_with_all_file_types_moved_upwards_in_worktree() -> anyhow::Result<()> insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:01e79c3 a/b/executable - 100644:3aac70f a/b/file - 120000:c4c364c a/b/link - 100644:a0d4277 a/sibling - "); +100755:01e79c3 a/b/executable +100644:3aac70f a/b/file +120000:c4c364c a/b/link +100644:a0d4277 a/sibling +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - └── a:40755 - ├── b:40755 - │ ├── executable:100755 - │ ├── file:100644 - │ └── link:120755 - └── sibling:100644 - "); +. +├── .git:40755 +└── a:40755 + ├── b:40755 + │ ├── executable:100755 + │ ├── file:100644 + │ └── link:120755 + └── sibling:100644 +"); Ok(()) } @@ -825,11 +825,11 @@ fn folder_with_all_file_types_moved_upwards_in_worktree() -> anyhow::Result<()> fn folder_with_all_file_types_moved_upwards_in_worktree_discard_selected() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("move-directory-into-sibling-file"); insta::assert_snapshot!(git_status(&repo)?, @r" - D a/b/executable - D a/b/file - D a/b/link - D a/sibling - "); +D a/b/executable +D a/b/file +D a/b/link +D a/sibling +"); // For `gitoxide` this looks like this: // D a/sibling // R a/b/executable → a/sibling/executable @@ -850,21 +850,21 @@ fn folder_with_all_file_types_moved_upwards_in_worktree_discard_selected() -> an insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:01e79c3 a/b/executable - 100644:3aac70f a/b/file - 120000:c4c364c a/b/link - 100644:a0d4277 a/sibling - "); +100755:01e79c3 a/b/executable +100644:3aac70f a/b/file +120000:c4c364c a/b/link +100644:a0d4277 a/sibling +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - └── a:40755 - ├── b:40755 - │ ├── executable:100755 - │ ├── file:100644 - │ └── link:120755 - └── sibling:100644 - "); +. +├── .git:40755 +└── a:40755 + ├── b:40755 + │ ├── executable:100755 + │ ├── file:100644 + │ └── link:120755 + └── sibling:100644 +"); Ok(()) } @@ -874,32 +874,32 @@ fn folder_with_all_file_types_moved_upwards_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario_slow("move-directory-into-sibling-file"); git(&repo).args(["add", "."]).run(); insta::assert_snapshot!(git_status(&repo)?, @r" - D a/sibling - R a/b/executable -> a/sibling/executable - R a/b/file -> a/sibling/file - R a/b/link -> a/sibling/link - "); +D a/sibling +R a/b/executable -> a/sibling/executable +R a/b/file -> a/sibling/file +R a/b/link -> a/sibling/link +"); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; assert!(dropped.is_empty()); insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100755:01e79c3 a/b/executable - 100644:3aac70f a/b/file - 120000:c4c364c a/b/link - 100644:a0d4277 a/sibling - "); +100755:01e79c3 a/b/executable +100644:3aac70f a/b/file +120000:c4c364c a/b/link +100644:a0d4277 a/sibling +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - └── a:40755 - ├── b:40755 - │ ├── executable:100755 - │ ├── file:100644 - │ └── link:120755 - └── sibling:100644 - "); +. +├── .git:40755 +└── a:40755 + ├── b:40755 + │ ├── executable:100755 + │ ├── file:100644 + │ └── link:120755 + └── sibling:100644 +"); Ok(()) } @@ -909,11 +909,11 @@ fn folder_with_all_file_types_moved_upwards_in_index() -> anyhow::Result<()> { fn all_file_types_deleted_in_index() -> anyhow::Result<()> { let (repo, _tmp) = writable_scenario("delete-all-file-types-valid-submodule"); insta::assert_snapshot!(git_status(&repo)?, @r" - D .gitmodules - D executable - D link - D submodule - "); +D .gitmodules +D executable +D link +D submodule +"); git(&repo).args(["add", "."]).run(); let dropped = discard_workspace_changes(&repo, worktree_changes_to_discard_specs(&repo))?; @@ -921,28 +921,28 @@ fn all_file_types_deleted_in_index() -> anyhow::Result<()> { insta::assert_snapshot!(git_status(&repo)?, @""); insta::assert_snapshot!(visualize_index(&**repo.index()?), @r" - 100644:51f8807 .gitmodules - 160000:a047f81 embedded-repository - 100755:86daf54 executable - 100644:d95f3ad file-to-remain - 120000:b158162 link - 160000:a047f81 submodule - "); +100644:51f8807 .gitmodules +160000:a047f81 embedded-repository +100755:86daf54 executable +100644:d95f3ad file-to-remain +120000:b158162 link +160000:a047f81 submodule +"); insta::assert_snapshot!(visualize_disk_tree_skip_dot_git(repo.workdir().unwrap())?, @r" - . - ├── .git:40755 - ├── .gitmodules:100644 - ├── embedded-repository:40755 - │ ├── .git:40755 - │ └── file:100644 - ├── executable:100755 - ├── file-to-remain:100644 - ├── link:120755 - └── submodule:40755 - ├── .git:100644 - └── file:100644 - "); +. +├── .git:40755 +├── .gitmodules:100644 +├── embedded-repository:40755 +│ ├── .git:40755 +│ └── file:100644 +├── executable:100755 +├── file-to-remain:100644 +├── link:120755 +└── submodule:40755 + ├── .git:100644 + └── file:100644 +"); insta::assert_snapshot!( std::fs::read_to_string(repo.workdir_path("submodule/.git").unwrap()) .expect("file can be read"), diff --git a/crates/but-workspace/tests/workspace/discard/hunk.rs b/crates/but-workspace/tests/workspace/discard/hunk.rs new file mode 100644 index 0000000000..1b0e3d6642 --- /dev/null +++ b/crates/but-workspace/tests/workspace/discard/hunk.rs @@ -0,0 +1,17 @@ +#[test] +#[ignore = "TBD"] +fn non_modifications_trigger_error() -> anyhow::Result<()> { + Ok(()) +} + +#[test] +#[ignore = "TBD"] +fn deletion_modification_addition_mixed_in_worktree() -> anyhow::Result<()> { + Ok(()) +} + +#[test] +#[ignore = "TBD"] +fn deletion_modification_addition_mixed_in_index() -> anyhow::Result<()> { + Ok(()) +} diff --git a/crates/but-workspace/tests/workspace/discard/mod.rs b/crates/but-workspace/tests/workspace/discard/mod.rs new file mode 100644 index 0000000000..f5f37bec12 --- /dev/null +++ b/crates/but-workspace/tests/workspace/discard/mod.rs @@ -0,0 +1,2 @@ +mod file; +mod hunk;