diff --git a/Cargo.lock b/Cargo.lock index 67cdac0d13..5c593d0625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,7 +760,6 @@ dependencies = [ "itertools 0.14.0", "md5", "serde", - "serial_test", "tempfile", "tracing", "url", @@ -3125,7 +3124,7 @@ dependencies = [ [[package]] name = "gix" version = "0.70.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-actor 0.33.2", "gix-attributes 0.24.0", @@ -3196,7 +3195,7 @@ dependencies = [ [[package]] name = "gix-actor" version = "0.33.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-date 0.9.3", @@ -3227,7 +3226,7 @@ dependencies = [ [[package]] name = "gix-attributes" version = "0.24.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-glob 0.18.0", @@ -3253,7 +3252,7 @@ dependencies = [ [[package]] name = "gix-bitmap" version = "0.2.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "thiserror 2.0.9", ] @@ -3270,7 +3269,7 @@ dependencies = [ [[package]] name = "gix-chunk" version = "0.4.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "thiserror 2.0.9", ] @@ -3278,7 +3277,7 @@ dependencies = [ [[package]] name = "gix-command" version = "0.4.1" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-path 0.10.14", @@ -3304,7 +3303,7 @@ dependencies = [ [[package]] name = "gix-commitgraph" version = "0.26.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-chunk 0.4.11", @@ -3318,7 +3317,7 @@ dependencies = [ [[package]] name = "gix-config" version = "0.43.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-config-value", @@ -3338,7 +3337,7 @@ dependencies = [ [[package]] name = "gix-config-value" version = "0.14.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3350,7 +3349,7 @@ dependencies = [ [[package]] name = "gix-credentials" version = "0.27.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-command", @@ -3379,7 +3378,7 @@ dependencies = [ [[package]] name = "gix-date" version = "0.9.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "itoa 1.0.11", @@ -3391,7 +3390,7 @@ dependencies = [ [[package]] name = "gix-diff" version = "0.50.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-attributes 0.24.0", @@ -3414,7 +3413,7 @@ dependencies = [ [[package]] name = "gix-dir" version = "0.12.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-discover 0.38.0", @@ -3449,7 +3448,7 @@ dependencies = [ [[package]] name = "gix-discover" version = "0.38.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "dunce", @@ -3479,7 +3478,7 @@ dependencies = [ [[package]] name = "gix-features" version = "0.40.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bytes", "crc32fast", @@ -3501,7 +3500,7 @@ dependencies = [ [[package]] name = "gix-filter" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "encoding_rs", @@ -3532,7 +3531,7 @@ dependencies = [ [[package]] name = "gix-fs" version = "0.13.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "fastrand", "gix-features 0.40.0", @@ -3554,7 +3553,7 @@ dependencies = [ [[package]] name = "gix-glob" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3576,7 +3575,7 @@ dependencies = [ [[package]] name = "gix-hash" version = "0.16.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "faster-hex", "serde", @@ -3597,7 +3596,7 @@ dependencies = [ [[package]] name = "gix-hashtable" version = "0.7.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-hash 0.16.0", "hashbrown 0.14.5", @@ -3620,7 +3619,7 @@ dependencies = [ [[package]] name = "gix-ignore" version = "0.13.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-glob 0.18.0", @@ -3661,7 +3660,7 @@ dependencies = [ [[package]] name = "gix-index" version = "0.38.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3700,7 +3699,7 @@ dependencies = [ [[package]] name = "gix-lock" version = "16.0.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-tempfile 16.0.0", "gix-utils 0.1.14", @@ -3710,7 +3709,7 @@ dependencies = [ [[package]] name = "gix-mailmap" version = "0.25.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-actor 0.33.2", @@ -3722,7 +3721,7 @@ dependencies = [ [[package]] name = "gix-merge" version = "0.3.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-command", @@ -3746,7 +3745,7 @@ dependencies = [ [[package]] name = "gix-negotiate" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "gix-commitgraph 0.26.0", @@ -3780,7 +3779,7 @@ dependencies = [ [[package]] name = "gix-object" version = "0.47.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-actor 0.33.2", @@ -3801,7 +3800,7 @@ dependencies = [ [[package]] name = "gix-odb" version = "0.67.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "arc-swap", "gix-date 0.9.3", @@ -3822,7 +3821,7 @@ dependencies = [ [[package]] name = "gix-pack" version = "0.57.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "clru", "gix-chunk 0.4.11", @@ -3843,7 +3842,7 @@ dependencies = [ [[package]] name = "gix-packetline" version = "0.18.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "faster-hex", @@ -3854,7 +3853,7 @@ dependencies = [ [[package]] name = "gix-packetline-blocking" version = "0.18.2" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "faster-hex", @@ -3878,7 +3877,7 @@ dependencies = [ [[package]] name = "gix-path" version = "0.10.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-trace 0.1.12", @@ -3890,7 +3889,7 @@ dependencies = [ [[package]] name = "gix-pathspec" version = "0.9.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "bstr", @@ -3904,7 +3903,7 @@ dependencies = [ [[package]] name = "gix-prompt" version = "0.9.1" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-command", "gix-config-value", @@ -3916,7 +3915,7 @@ dependencies = [ [[package]] name = "gix-protocol" version = "0.48.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-credentials", @@ -3953,7 +3952,7 @@ dependencies = [ [[package]] name = "gix-quote" version = "0.4.15" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-utils 0.1.14", @@ -3985,7 +3984,7 @@ dependencies = [ [[package]] name = "gix-ref" version = "0.50.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-actor 0.33.2", "gix-features 0.40.0", @@ -4006,7 +4005,7 @@ dependencies = [ [[package]] name = "gix-refspec" version = "0.28.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-hash 0.16.0", @@ -4019,7 +4018,7 @@ dependencies = [ [[package]] name = "gix-revision" version = "0.32.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "bstr", @@ -4052,7 +4051,7 @@ dependencies = [ [[package]] name = "gix-revwalk" version = "0.18.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "gix-commitgraph 0.26.0", "gix-date 0.9.3", @@ -4078,7 +4077,7 @@ dependencies = [ [[package]] name = "gix-sec" version = "0.10.11" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "gix-path 0.10.14", @@ -4090,7 +4089,7 @@ dependencies = [ [[package]] name = "gix-shallow" version = "0.2.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-hash 0.16.0", @@ -4102,7 +4101,7 @@ dependencies = [ [[package]] name = "gix-status" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "filetime", @@ -4124,7 +4123,7 @@ dependencies = [ [[package]] name = "gix-submodule" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-config", @@ -4153,7 +4152,7 @@ dependencies = [ [[package]] name = "gix-tempfile" version = "16.0.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "dashmap", "gix-fs 0.13.0", @@ -4198,7 +4197,7 @@ checksum = "04bdde120c29f1fc23a24d3e115aeeea3d60d8e65bab92cc5f9d90d9302eb952" [[package]] name = "gix-trace" version = "0.1.12" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "tracing-core", ] @@ -4206,7 +4205,7 @@ dependencies = [ [[package]] name = "gix-transport" version = "0.45.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "base64 0.22.1", "bstr", @@ -4242,7 +4241,7 @@ dependencies = [ [[package]] name = "gix-traverse" version = "0.44.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bitflags 2.6.0", "gix-commitgraph 0.26.0", @@ -4258,7 +4257,7 @@ dependencies = [ [[package]] name = "gix-url" version = "0.29.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-features 0.40.0", @@ -4282,7 +4281,7 @@ dependencies = [ [[package]] name = "gix-utils" version = "0.1.14" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "fastrand", @@ -4302,7 +4301,7 @@ dependencies = [ [[package]] name = "gix-validate" version = "0.9.3" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "thiserror 2.0.9", @@ -4330,7 +4329,7 @@ dependencies = [ [[package]] name = "gix-worktree" version = "0.39.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-attributes 0.24.0", @@ -4349,7 +4348,7 @@ dependencies = [ [[package]] name = "gix-worktree-state" version = "0.17.0" -source = "git+https://github.com/GitoxideLabs/gitoxide?rev=5c327bbfeb7c685a93962e087f72d1083a768b2d#5c327bbfeb7c685a93962e087f72d1083a768b2d" +source = "git+https://github.com/GitoxideLabs/gitoxide?branch=improvements#4ae5512abceff3510e64e55220035bbf9a53f884" dependencies = [ "bstr", "gix-features 0.40.0", diff --git a/Cargo.toml b/Cargo.toml index 1c6f6e053e..e9d5c43b43 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 = "5c327bbfeb7c685a93962e087f72d1083a768b2d", default-features = false, features = [ +gix = { git = "https://github.com/GitoxideLabs/gitoxide", branch = "improvements", default-features = false, features = [ ] } gix-testtools = "0.15.0" insta = "1.41.1" diff --git a/crates/but-core/tests/core/diff/worktree_changes.rs b/crates/but-core/tests/core/diff/worktree_changes.rs index c5a029be76..8aece79cd7 100644 --- a/crates/but-core/tests/core/diff/worktree_changes.rs +++ b/crates/but-core/tests/core/diff/worktree_changes.rs @@ -234,6 +234,17 @@ fn added_in_unborn() -> Result<()> { Ok(()) } +#[test] +fn sparse() -> Result<()> { + let repo = repo_in("sparse", "non-cone")?; + let err = diff::worktree_changes(&repo).unwrap_err(); + assert!( + err.to_string().contains("sparse"), + "Currently status doesn't run on sparse indices, but it could if it would unsparse it" + ); + Ok(()) +} + #[test] fn submodule_added_in_unborn() -> Result<()> { let repo = repo("submodule-added-unborn")?; @@ -1222,16 +1233,20 @@ fn unified_diffs( .collect() } -pub fn repo(fixture_name: &str) -> anyhow::Result { - let root = gix_testtools::scripted_fixture_read_only("worktree-changes.sh") +pub fn repo_in(fixture_name: &str, name: &str) -> anyhow::Result { + let root = gix_testtools::scripted_fixture_read_only(format!("{}.sh", fixture_name)) .map_err(anyhow::Error::from_boxed)?; - let worktree_root = root.join(fixture_name); - Ok(gix::open(worktree_root)?) + let worktree_root = root.join(name); + Ok(gix::open_opts( + worktree_root, + gix::open::Options::isolated(), + )?) +} + +pub fn repo(fixture_name: &str) -> anyhow::Result { + repo_in("worktree-changes", fixture_name) } pub fn repo_unix(fixture_name: &str) -> anyhow::Result { - let root = gix_testtools::scripted_fixture_read_only("worktree-changes-unix.sh") - .map_err(anyhow::Error::from_boxed)?; - let worktree_root = root.join(fixture_name); - Ok(gix::open(worktree_root)?) + repo_in("worktree-changes-unix", fixture_name) } diff --git a/crates/but-core/tests/fixtures/generated-archives/sparse.tar b/crates/but-core/tests/fixtures/generated-archives/sparse.tar new file mode 100644 index 0000000000..a668bc8ee2 Binary files /dev/null and b/crates/but-core/tests/fixtures/generated-archives/sparse.tar differ diff --git a/crates/but-core/tests/fixtures/sparse.sh b/crates/but-core/tests/fixtures/sparse.sh new file mode 100644 index 0000000000..73988f0ce9 --- /dev/null +++ b/crates/but-core/tests/fixtures/sparse.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -eu -o pipefail + +git init -q non-cone +(cd non-cone + touch a b + mkdir c1 + (cd c1 && touch a b && mkdir c2 && cd c2 && touch a b) + (cd c1 && mkdir c3 && cd c3 && touch a b) + mkdir d + (cd d && touch a b && mkdir c4 && cd c4 && touch a b c5) + + git add . + git commit -m "init" + + git sparse-checkout set c1/c2 --sparse-index +) + diff --git a/crates/but-workspace/Cargo.toml b/crates/but-workspace/Cargo.toml index 1422f359d0..6cd995dd67 100644 --- a/crates/but-workspace/Cargo.toml +++ b/crates/but-workspace/Cargo.toml @@ -40,5 +40,4 @@ md5 = "0.7.0" gix-testtools = "0.15.0" gitbutler-testsupport.workspace = true insta = "1.42.1" -but-core = { workspace = true, features = ["testing"] } -serial_test = "3.2.0" \ No newline at end of file +but-core = { workspace = true, features = ["testing"] } \ No newline at end of file diff --git a/crates/but-workspace/src/commit_engine/mod.rs b/crates/but-workspace/src/commit_engine/mod.rs index 0529bcb147..d3de8177e9 100644 --- a/crates/but-workspace/src/commit_engine/mod.rs +++ b/crates/but-workspace/src/commit_engine/mod.rs @@ -20,16 +20,20 @@ mod plumbing; /// The place to apply the [change-specifications](DiffSpec) to. /// /// Note that any commit this instance points to will be the basis to apply all changes to. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum Destination { - /// Create a new commit on top of the given `Some(commit)`, so it will be the sole parent + /// Create a new commit on top of the given `parent_commit_id`, so it will be the sole parent /// of the newly created commit, making it its ancestor. - /// To create a commit at the position of the first commit of a branch, the parent has to be the merge-base with the *target branch*. - /// - /// If the commit is `None`, the base-state for the new commit will be an empty tree and the new commit will be the first one - /// (i.e. have no parent). This is the case when `HEAD` is unborn. If `HEAD` is detached, this is a failure. - ParentForNewCommit(Option), - /// Amend the given commit. + NewCommit { + /// If `None`, the base-state for the new commit will be an empty tree and the new commit will be the first one + /// (i.e. have no parent). This is the case when `HEAD` is unborn. If `HEAD` is detached, this is a failure. + /// + /// To create a commit at the position of the first commit of a branch, the parent has to be the merge-base with the *target branch*. + parent_commit_id: Option, + /// Use `message` as commit message for the new commit. + message: String, + }, + /// Amend all changes to the given commit, leaving all other aspects of the commit unchanged. AmendCommit(gix::ObjectId), } @@ -91,8 +95,6 @@ pub struct CreateCommitOutcome { pub rejected_specs: Vec, /// The newly created commit, or `None` if no commit could be created as all changes-requests were rejected. pub new_commit: Option, - /// Only set when `HEAD` was updated as it was unborn, and we created the first commit. - pub ref_edit: Option, } /// Additional information about the outcome of a [`create_tree()`] call. @@ -109,7 +111,7 @@ pub struct CreateTreeOutcome { /// Like [`create_commit()`], but lower-level and only returns a new tree, without finally associating it with a commit. pub fn create_tree( repo: &gix::Repository, - destination: Destination, + destination: &Destination, origin_commit: Option, changes: Vec, context_lines: u32, @@ -119,8 +121,14 @@ pub fn create_tree( } let target_tree = match destination { - Destination::ParentForNewCommit(None) => gix::ObjectId::empty_tree(repo.object_hash()), - Destination::ParentForNewCommit(Some(base_commit)) + Destination::NewCommit { + parent_commit_id: None, + .. + } => gix::ObjectId::empty_tree(repo.object_hash()), + Destination::NewCommit { + parent_commit_id: Some(base_commit), + .. + } | Destination::AmendCommit(base_commit) => { but_core::Commit::from_id(base_commit.attach(repo))? .tree_id()? @@ -194,19 +202,9 @@ pub fn create_tree( }) } -/// Control how [`create_commit()`] alters references after creating the new commit. -#[derive(Debug, Copy, Clone)] -pub enum RefHandling { - /// If the commit is created on top of a commit that the `HEAD` ref is currently pointing to, then update it to point to the new commit. - UpdateHEADRefForTipCommits, - /// Do not touch any ref, only create a commit. - None, -} - /// Alter the single `destination` in a given `frame` with as many `changes` as possible and write new objects into `repo`, /// but only if the commit succeeds. /// If `origin_commit` is `Some(commit)`, all changes are considered to originate from the given commit, otherwise they originate from the worktree. -/// Use `message` as commit message. /// `context_lines` is the amount of lines of context included in each [`HunkHeader`], and the value that will be used to recover the existing hunks, /// so that the hunks can be matched. /// @@ -220,63 +218,64 @@ pub fn create_commit( destination: Destination, origin_commit: Option, changes: Vec, - message: &str, context_lines: u32, - ref_handling: RefHandling, ) -> anyhow::Result { - let parents = match destination { - Destination::ParentForNewCommit(None) => Vec::new(), - Destination::ParentForNewCommit(Some(parent)) => vec![parent], - Destination::AmendCommit(_) => { - todo!("get parents of the given commit ") - } + let parents = match &destination { + Destination::NewCommit { + parent_commit_id: None, + .. + } => Vec::new(), + Destination::NewCommit { + parent_commit_id: Some(parent), + .. + } => vec![*parent], + Destination::AmendCommit(commit_id) => commit_id + .attach(repo) + .object()? + .peel_to_commit()? + .parent_ids() + .map(|id| id.detach()) + .collect(), }; - if parents.len() > 1 { + if !matches!(destination, Destination::AmendCommit(_)) && parents.len() > 1 { bail!("cannot currently handle more than 1 parent") } - let ref_name_to_update = repo - .head_name()? - .context("Refusing to commit into a detached HEAD")?; - let ref_name_to_update = match ref_handling { - RefHandling::UpdateHEADRefForTipCommits => { - if let &[parent] = &parents[..] { - new_commit_is_on_top_of_tip(repo, ref_name_to_update.as_ref(), parent)? - .then_some(ref_name_to_update) - } else if repo.head()?.is_unborn() { - Some(ref_name_to_update) - } else { - None - } - } - RefHandling::None => None, - }; - let CreateTreeOutcome { rejected_specs, new_tree, - } = create_tree(repo, destination, origin_commit, changes, context_lines)?; - let (new_commit, ref_edit) = if let Some(new_tree) = new_tree { - let (author, committer) = repo.commit_signatures()?; - let (new_commit, ref_edit) = plumbing::create_commit( - repo, - ref_name_to_update, - author, - committer, - message, - new_tree, - parents, - None, - )?; - (Some(new_commit), ref_edit) + } = create_tree(repo, &destination, origin_commit, changes, context_lines)?; + let new_commit = if let Some(new_tree) = new_tree { + match destination { + Destination::NewCommit { + message, + parent_commit_id: _, + } => { + let (author, committer) = repo.commit_signatures()?; + let (new_commit, _ref_edit) = plumbing::create_commit( + repo, None, author, committer, &message, new_tree, parents, None, + )?; + Some(new_commit) + } + Destination::AmendCommit(commit_id) => { + let mut commit = commit_id + .attach(repo) + .object()? + .peel_to_commit()? + .decode()? + .to_owned(); + commit.tree = new_tree; + let (new_commit, _ref_edit) = plumbing::create_given_commit(repo, None, commit)?; + Some(new_commit) + } + } } else { - (None, None) + None }; Ok(CreateCommitOutcome { rejected_specs, new_commit, - ref_edit, }) } @@ -288,17 +287,6 @@ fn into_err_spec(input: &mut PossibleChange) { }; } -fn new_commit_is_on_top_of_tip( - repo: &gix::Repository, - name: &gix::refs::FullNameRef, - parent: gix::ObjectId, -) -> anyhow::Result { - let Some(head_ref) = repo.try_find_reference(name)? else { - return Ok(true); - }; - Ok(head_ref.id() == *parent) -} - type PossibleChange = Result; /// Apply `changes` to `changes_base_tree` and return the newly written tree. diff --git a/crates/but-workspace/src/commit_engine/plumbing.rs b/crates/but-workspace/src/commit_engine/plumbing.rs index 79d822b2c8..aa1b3f5b6b 100644 --- a/crates/but-workspace/src/commit_engine/plumbing.rs +++ b/crates/but-workspace/src/commit_engine/plumbing.rs @@ -23,7 +23,7 @@ pub fn create_commit( parents: impl IntoIterator>, commit_headers: Option, ) -> anyhow::Result<(gix::ObjectId, Option)> { - let mut commit = gix::objs::Commit { + let commit = gix::objs::Commit { message: message.into(), tree, author, @@ -32,23 +32,43 @@ pub fn create_commit( parents: parents.into_iter().map(Into::into).collect(), extra_headers: commit_headers.unwrap_or_default().into(), }; + create_given_commit(repo, update_ref, commit) +} +/// Use the given commit and possibly sign it, replacing a possibly existing signature, +/// or removing the signature if GitButler is not configured to keep it. +/// +/// Signatures will be removed automatically if signing is disabled to prevent an amended commit +/// to use the old signature. They will also be added or replace existing signatures. +#[allow(clippy::too_many_arguments)] +pub fn create_given_commit( + repo: &gix::Repository, + update_ref: Option, + mut commit: gix::objs::Commit, +) -> anyhow::Result<(gix::ObjectId, Option)> { + if let Some(pos) = commit + .extra_headers() + .find_pos(gix::objs::commit::SIGNATURE_FIELD_NAME) + { + commit.extra_headers.remove(pos); + } if repo.git_settings()?.gitbutler_sign_commits.unwrap_or(false) { let mut buf = Vec::new(); commit.write_to(&mut buf)?; - let signature = sign_buffer(repo, &buf); - match signature { + match sign_buffer(repo, &buf) { Ok(signature) => { - commit.extra_headers.push(("gpgsig".into(), signature)); + commit + .extra_headers + .push((gix::objs::commit::SIGNATURE_FIELD_NAME.into(), signature)); } - Err(e) => { + Err(err) => { // If signing fails, turn off signing automatically and let everyone know, repo.set_git_settings(&GitConfigSettings { gitbutler_sign_commits: Some(false), ..GitConfigSettings::default() })?; return Err( - anyhow!("Failed to sign commit: {}", e).context(Code::CommitSigningFailed) + anyhow!("Failed to sign commit: {}", err).context(Code::CommitSigningFailed) ); } } @@ -58,8 +78,8 @@ pub fn create_commit( let refedit = if let Some(refname) = update_ref { // TODO:(ST) should this be something more like what Git does (also in terms of reflog message)? // Probably should support making a commit in full with `gix`. - let log_message = message; - let edit = update_reference(repo, refname, new_commit_id, log_message.into())?; + let log_message = commit.message; + let edit = update_reference(repo, refname, new_commit_id, log_message)?; Some(edit) } else { None diff --git a/crates/but-workspace/src/commit_engine/ui.rs b/crates/but-workspace/src/commit_engine/ui.rs index d3889bbec5..0148ea42de 100644 --- a/crates/but-workspace/src/commit_engine/ui.rs +++ b/crates/but-workspace/src/commit_engine/ui.rs @@ -50,7 +50,6 @@ impl From for CreateCommitOutcome { super::CreateCommitOutcome { rejected_specs, new_commit, - ref_edit: _, }: super::CreateCommitOutcome, ) -> Self { CreateCommitOutcome { diff --git a/crates/but-workspace/tests/fixtures/scenario/two-signed-commits-with-line-offset.sh b/crates/but-workspace/tests/fixtures/scenario/two-signed-commits-with-line-offset.sh new file mode 100644 index 0000000000..de95dc791b --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/two-signed-commits-with-line-offset.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +### Description +# A single branch with two signed commits. The first commit has 10 lines, the second adds +# another 10 lines to the top of the file. +# Large numbers are used make fuzzy-patch application harder. +set -eu -o pipefail + +ssh-keygen -t rsa -b 2048 -C "test@example.com" -N "" -f signature.key + +git init +git config gpg.format ssh +git config user.signingKey "$PWD/signature.key" +git config GitButler.signCommits true + +export "GIT_CONFIG_COUNT=2" +export "GIT_CONFIG_KEY_0=commit.gpgsign" +export "GIT_CONFIG_VALUE_0=true" +export "GIT_CONFIG_KEY_1=init.defaultBranch" +export "GIT_CONFIG_VALUE_1=main" + +seq 10 20 >file +git add file && git commit -m init && git tag first-commit + +seq 20 >file && git commit -am "insert 10 lines to the top" + diff --git a/crates/but-workspace/tests/workspace/commit_engine.rs b/crates/but-workspace/tests/workspace/commit_engine.rs deleted file mode 100644 index ad6e62311d..0000000000 --- a/crates/but-workspace/tests/workspace/commit_engine.rs +++ /dev/null @@ -1,948 +0,0 @@ -mod new_commit { - use crate::commit_engine::utils::{ - commit_whole_files_and_all_hunks_from_workspace, stable_env, to_change_specs_all_hunks, - to_change_specs_all_hunks_with_context_lines, to_change_specs_whole_file, visualize_tree, - writable_scenario, writable_scenario_execute, write_sequence, CONTEXT_LINES, - }; - use but_workspace::commit_engine; - use but_workspace::commit_engine::{CreateCommitOutcome, Destination, DiffSpec, RefHandling}; - use gix::prelude::ObjectIdExt; - use serial_test::serial; - - #[test] - #[serial] - fn from_unborn_head() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("unborn-untracked"); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(None), - "the commit message", - )?; - insta::assert_debug_snapshot!(&outcome, @r#" - CreateCommitOutcome { - rejected_specs: [], - new_commit: Some( - Sha1(2eb2e90e37a7f23052db17b67b91eb5c4a7a1e81), - ), - ref_edit: Some( - RefEdit { - change: Update { - log: LogChange { - mode: AndReference, - force_create_reflog: false, - message: "the commit message", - }, - expected: Any, - new: Object( - Sha1(2eb2e90e37a7f23052db17b67b91eb5c4a7a1e81), - ), - }, - name: FullName( - "refs/heads/main", - ), - deref: false, - }, - ), - } - "#); - - let new_commit_id = outcome.new_commit.expect("a new commit was created"); - assert_eq!( - repo.head_id()?, - new_commit_id, - "HEAD should have been updated as it's the top of the tip" - ); - assert_eq!( - repo.head_ref()?.expect("not detached").name().as_bstr(), - "refs/heads/main", - "it kept the head-ref" - ); - - let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; - assert_eq!(new_commit.message_raw()?, "the commit message"); - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 861d6e2 - └── not-yet-tracked:100644:d95f3ad "content\n" - "#); - - std::fs::write( - repo.work_dir().expect("non-bare").join("new-untracked"), - "new-content", - )?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(new_commit_id)), - "the second commit", - )?; - - insta::assert_debug_snapshot!(&outcome, @r#" - CreateCommitOutcome { - rejected_specs: [], - new_commit: Some( - Sha1(9fa45065e99a2f0492bca947cf462dfafd905516), - ), - ref_edit: Some( - RefEdit { - change: Update { - log: LogChange { - mode: AndReference, - force_create_reflog: false, - message: "the second commit", - }, - expected: MustExistAndMatch( - Object( - Sha1(2eb2e90e37a7f23052db17b67b91eb5c4a7a1e81), - ), - ), - new: Object( - Sha1(9fa45065e99a2f0492bca947cf462dfafd905516), - ), - }, - name: FullName( - "refs/heads/main", - ), - deref: false, - }, - ), - } - "#); - let current_tip = outcome.new_commit.expect("a new commit was created"); - let head_ref = repo.head_ref()?.expect("not detached"); - assert_eq!(head_ref.id(), current_tip, "HEAD should have been updated"); - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - a004469 - ├── new-untracked:100644:72278a7 "new-content" - └── not-yet-tracked:100644:d95f3ad "content\n" - "#); - Ok(()) - } - - #[test] - #[serial] - #[cfg(unix)] - fn from_unborn_head_all_file_types() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario_execute("unborn-untracked-all-file-types"); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(None), - "the commit message", - )?; - - assert_eq!( - outcome.rejected_specs, - Vec::new(), - "everything was committed" - ); - let new_commit_id = outcome.new_commit.expect("a new commit was created"); - assert_eq!( - repo.head_id()?, - new_commit_id, - "HEAD should have been updated as it's the top of the tip" - ); - assert_eq!( - repo.head_ref()?.expect("not detached").name().as_bstr(), - "refs/heads/main", - "it kept the head-ref" - ); - - let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; - assert_eq!(new_commit.message_raw()?, "the commit message"); - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 7f802e9 - ├── link:120000:faf96c1 "untracked" - ├── untracked:100644:d95f3ad "content\n" - └── untracked-exe:100755:86daf54 "exe\n" - "#); - - Ok(()) - } - - #[test] - #[serial] - #[cfg(unix)] - fn from_first_commit_all_file_types_changed() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario_execute("all-file-types-changed"); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(repo.rev_parse_single("HEAD")?.into())), - "the commit message", - )?; - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 9be09ac - ├── soon-executable:100755:d95f3ad "content\n" - ├── soon-file-not-link:100644:72f007b "ordinary content\n" - └── soon-not-executable:100644:86daf54 "exe\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn unborn_with_added_submodules() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("unborn-with-submodules"); - let worktree_changes = but_core::diff::worktree_changes(&repo)?; - let outcome = but_workspace::commit_engine::create_commit( - &repo, - Destination::ParentForNewCommit(None), - None, - to_change_specs_whole_file(worktree_changes), - "submodules have to be given as whole files but can then be handled correctly (but without Git's special handling)", - CONTEXT_LINES, - RefHandling::None, - )?; - - assert_eq!( - outcome.rejected_specs, - vec![], - "Everything could be added to the repository" - ); - let tree = visualize_tree(&repo, &outcome)?; - // The `module` is actually a repository inside the main repository, but we add it as 'embedded repository'. - // It's a thing, it's just that Git won't know how to obtain the submodule then. - insta::assert_snapshot!(tree, @r#" - 6260c86 - ├── .gitmodules:100644:49dc605 "[submodule \"m1\"]\n\tpath = m1\n\turl = ./module\n" - ├── m1:160000:a047f81 - └── module:160000:a047f81 - "#); - Ok(()) - } - - #[test] - #[serial] - fn deletions() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("delete-all-file-types"); - let head_commit = repo.rev_parse_single("HEAD")?; - insta::assert_snapshot!(gitbutler_testsupport::visualize_gix_tree(head_commit.object()?.peel_to_tree()?.id()), @r#" - cecc2da - ├── .gitmodules:100644:51f8807 "[submodule \"submodule\"]\n\tpath = submodule\n\turl = ./embedded-repository\n" - ├── embedded-repository:160000:a047f81 - ├── executable:100755:86daf54 "exe\n" - ├── file-to-remain:100644:d95f3ad "content\n" - ├── link:120000:b158162 "file-to-remain" - └── submodule:160000:a047f81 - "#); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(head_commit.into())), - "deletions maybe a bit special", - )?; - - insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" - c15318d - └── file-to-remain:100644:d95f3ad "content\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn renames() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario_execute("all-file-types-renamed-and-modified"); - let head_commit = repo.rev_parse_single("HEAD")?; - insta::assert_snapshot!(gitbutler_testsupport::visualize_gix_tree(head_commit.object()?.peel_to_tree()?.id()), @r#" - 3fd29f0 - ├── executable:100755:01e79c3 "1\n2\n3\n" - ├── file:100644:3aac70f "5\n6\n7\n8\n" - └── link:120000:c4c364c "nonexisting-target" - "#); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(head_commit.into())), - "deletions maybe a bit special", - )?; - - insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" - 0236fb1 - ├── executable-renamed:100755:94ebaf9 "1\n2\n3\n4\n" - ├── file-renamed:100644:66f816c "5\n6\n7\n8\n9\n" - └── link-renamed:120000:94e4e07 "other-nonexisting-target" - "#); - Ok(()) - } - - #[test] - #[serial] - fn submodule_typechanges() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("submodule-typechanges"); - let worktree_changes = but_core::diff::worktree_changes(&repo)?; - insta::assert_debug_snapshot!(worktree_changes.changes, @r#" - [ - TreeChange { - path: ".gitmodules", - status: Modification { - previous_state: ChangeState { - id: Sha1(51f8807c330e4ae8643ca943231cc6e176038aca), - kind: Blob, - }, - state: ChangeState { - id: Sha1(57fc33bc66d69e4df4ab23c33ae1101e67e56079), - kind: Blob, - }, - flags: None, - }, - }, - TreeChange { - path: "file", - status: Modification { - previous_state: ChangeState { - id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), - kind: Blob, - }, - state: ChangeState { - id: Sha1(a047f8183ba2bb7eb00ef89e60050c5fde740483), - kind: Commit, - }, - flags: Some( - TypeChange, - ), - }, - }, - TreeChange { - path: "submodule", - status: Modification { - previous_state: ChangeState { - id: Sha1(a047f8183ba2bb7eb00ef89e60050c5fde740483), - kind: Commit, - }, - state: ChangeState { - id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), - kind: Blob, - }, - flags: Some( - TypeChange, - ), - }, - }, - ] - "#); - let outcome = but_workspace::commit_engine::create_commit( - &repo, - Destination::ParentForNewCommit(Some(repo.rev_parse_single("HEAD")?.into())), - None, - to_change_specs_whole_file(worktree_changes), - "submodules have to be given as whole files but can then be handled correctly (but without Git's special handling)", - CONTEXT_LINES, - RefHandling::None, - )?; - - assert_eq!( - outcome.rejected_specs, - vec![], - "Everything could be added to the repository" - ); - let tree = visualize_tree(&repo, &outcome)?; - // The `module` is actually a repository inside the main repository, but we add it as 'embedded repository'. - // It's a thing, it's just that Git won't know how to obtain the submodule then. - insta::assert_snapshot!(tree, @r#" - 05b8ed2 - ├── .gitmodules:100644:57fc33b "[submodule \"submodule\"]\n\tpath = file\n\turl = ./embedded-repository\n" - ├── embedded-repository:160000:a047f81 - ├── file:160000:a047f81 - └── submodule:100644:d95f3ad "content\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn commit_to_one_below_tip() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("two-commits-with-line-offset"); - write_sequence(&repo, "file", [(20, Some(40)), (80, None), (30, Some(50))])?; - let first_commit = - Destination::ParentForNewCommit(Some(repo.rev_parse_single("first-commit")?.into())); - let outcome_ctx_0 = commit_whole_files_and_all_hunks_from_workspace( - &repo, - first_commit, - "we apply a change with line offsets on top of the first commit, so the patch wouldn't apply without fuzzy matching.", - )?; - - let tree = visualize_tree(&repo, &outcome_ctx_0)?; - insta::assert_snapshot!(tree, @r#" - 754a70c - └── file:100644:cc418b0 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n76\n77\n78\n79\n80\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn commit_to_one_below_tip_with_three_context_lines() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("two-commits-with-line-offset"); - write_sequence(&repo, "file", [(20, Some(40)), (80, None), (30, Some(50))])?; - for context_lines in [0, 3, 5] { - let first_commit = Destination::ParentForNewCommit(Some( - repo.rev_parse_single("first-commit")?.into(), - )); - - let outcome = but_workspace::commit_engine::create_commit( - &repo, - first_commit, - None, - to_change_specs_all_hunks_with_context_lines( - &repo, - but_core::diff::worktree_changes(&repo)?, - context_lines, - )?, - "When using context lines, we'd still think this works just like before", - context_lines, - RefHandling::None, - )?; - - assert_eq!( - outcome.new_commit.map(|id| id.to_string()), - Some("a33e9992196d88b09118158608acf4234b3273a9".to_string()) - ); - let tree = visualize_tree(&repo, &outcome)?; - assert_eq!( - tree, - r#"754a70c -└── file:100644:cc418b0 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n76\n77\n78\n79\n80\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" -"# - ); - } - Ok(()) - } - - #[test] - #[serial] - fn commit_to_branches_below_merge_commit() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset"); - - write_sequence(&repo, "file", [(1, 20), (40, 50)])?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(repo.rev_parse_single("B")?.into())), - "a new commit onto B, changing only the lines that it wrote", - )?; - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - a38c1c3 - └── file:100644:12121fe "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" - "#); - - write_sequence(&repo, "file", [(40, 50), (10, 30)])?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(repo.rev_parse_single("A")?.into())), - "a new commit onto A, changing only the lines that it wrote", - )?; - - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 704f5ca - └── file:100644:bc33e02 "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn commit_whole_file_to_conflicting_position() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset"); - - // rewrite all lines so changes cover both branches - write_sequence(&repo, "file", [(40, 70)])?; - for conflicting_parent_commit in ["A", "B", "main"] { - let parent_commit = repo.rev_parse_single(conflicting_parent_commit)?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(parent_commit.into())), - "this commit can't be done as it covers multiple commits, which will conflict on cherry-picking", - )?; - assert_eq!( - outcome, - CreateCommitOutcome { - rejected_specs: to_change_specs_all_hunks( - &repo, - but_core::diff::worktree_changes(&repo)? - )?, - new_commit: None, - ref_edit: None, - }, - "It shouldn't produce a commit and clearly mark the conflicting specs" - ); - } - - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(repo.head_id()?.into())), - "but it can be applied directly to the tip, the merge commit itself, it always works", - )?; - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 5bbee6d - └── file:100644:1c9325b "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn commit_whole_file_to_conflicting_position_one_unconflicting_file_remains( - ) -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset-two-files"); - - // rewrite all lines so changes cover both branches - write_sequence(&repo, "file", [(40, 70)])?; - // Change the second file to be non-conflicting, just the half the lines in the middle - write_sequence(&repo, "other-file", [(35, 44), (80, 90), (66, 75)])?; - for conflicting_parent_commit in ["A", "B", "main"] { - let parent_commit = repo.rev_parse_single(conflicting_parent_commit)?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(parent_commit.into())), - "this commit can't be done as it covers multiple commits, which will conflict on cherry-picking", - )?; - assert_eq!( - outcome.rejected_specs, - Vec::from_iter( - to_change_specs_all_hunks(&repo, but_core::diff::worktree_changes(&repo)?)? - .first() - .cloned() - ), - "It still produces a commit as one file was non-conflicting, keeping the base version of the non-conflicting file" - ); - // Different bases mean different base versions for the conflicting file. - if conflicting_parent_commit == "A" { - insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" - 0816d13 - ├── file:100644:0ff3bbb "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" - └── other-file:100644:593469b "35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n" - "#); - } else if conflicting_parent_commit == "B" { - insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" - df6d629 - ├── file:100644:1f1542b "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n" - └── other-file:100644:a935ec9 "80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n" - "#); - } else if conflicting_parent_commit == "main" { - insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" - d5d6e30 - ├── file:100644:e33f5e9 "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" - └── other-file:100644:240fe08 "80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n" - "#); - } - } - - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(repo.head_id()?.into())), - "but it can be applied directly to the tip, the merge commit itself, it always works", - )?; - let tree = visualize_tree(&repo, &outcome)?; - insta::assert_snapshot!(tree, @r#" - 7d017dd - ├── file:100644:1c9325b "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n" - └── other-file:100644:4223e57 "35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n" - "#); - Ok(()) - } - - #[test] - #[serial] - fn unborn_untracked_worktree_filters_are_applied_to_whole_files() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("unborn-untracked-crlf"); - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(None), - "the commit message", - )?; - insta::assert_debug_snapshot!(&outcome, @r#" - CreateCommitOutcome { - rejected_specs: [], - new_commit: Some( - Sha1(f45994afa0d26558ae4bea626917b70f8863a29b), - ), - ref_edit: Some( - RefEdit { - change: Update { - log: LogChange { - mode: AndReference, - force_create_reflog: false, - message: "the commit message", - }, - expected: Any, - new: Object( - Sha1(f45994afa0d26558ae4bea626917b70f8863a29b), - ), - }, - name: FullName( - "refs/heads/main", - ), - deref: false, - }, - ), - } - "#); - - let new_commit_id = outcome.new_commit.expect("a new commit was created"); - assert_eq!( - repo.head_id()?, - new_commit_id, - "HEAD should have been updated" - ); - assert_eq!( - repo.head_ref()?.expect("not detached").name().as_bstr(), - "refs/heads/main", - "it kept the head-ref" - ); - - let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; - assert_eq!(new_commit.message_raw()?, "the commit message"); - - // What's in Git is unix style newlines - let tree = gitbutler_testsupport::visualize_gix_tree(new_commit.tree_id()?); - insta::assert_snapshot!(tree, @r#" - d5949f1 - └── not-yet-tracked:100644:1191247 "1\n2\n" - "#); - - std::fs::write( - repo.work_dir().expect("non-bare").join("new-untracked"), - "one\r\ntwo\r\n", - )?; - let outcome = commit_whole_files_and_all_hunks_from_workspace( - &repo, - Destination::ParentForNewCommit(Some(new_commit_id)), - "the second commit", - )?; - - insta::assert_debug_snapshot!(&outcome, @r#" - CreateCommitOutcome { - rejected_specs: [], - new_commit: Some( - Sha1(9218f64284f5b8f31c42aed238ec89ff1836a253), - ), - ref_edit: Some( - RefEdit { - change: Update { - log: LogChange { - mode: AndReference, - force_create_reflog: false, - message: "the second commit", - }, - expected: MustExistAndMatch( - Object( - Sha1(f45994afa0d26558ae4bea626917b70f8863a29b), - ), - ), - new: Object( - Sha1(9218f64284f5b8f31c42aed238ec89ff1836a253), - ), - }, - name: FullName( - "refs/heads/main", - ), - deref: false, - }, - ), - } - "#); - let current_tip = outcome.new_commit.expect("a new commit was created"); - let head_ref = repo.head_ref()?.expect("not detached"); - assert_eq!(head_ref.id(), current_tip, "HEAD should have been updated"); - - let tree = gitbutler_testsupport::visualize_gix_tree( - outcome - .new_commit - .expect("no rejected changes") - .attach(&repo) - .object()? - .peel_to_commit()? - .tree_id()?, - ); - insta::assert_snapshot!(tree, @r#" - cef7412 - ├── new-untracked:100644:814f4a4 "one\ntwo\n" - └── not-yet-tracked:100644:1191247 "1\n2\n" - "#); - - Ok(()) - } - - #[test] - #[ignore = "TBD"] - fn worktree_filters_are_applied_to_whole_hunks() {} - - #[test] - #[ignore = "TBD"] - fn figure_out_commit_signature_test() {} - - #[test] - #[serial] - fn validate_no_change_on_noop() -> anyhow::Result<()> { - let _env = stable_env(); - - let (repo, _tmp) = writable_scenario("two-commits-with-line-offset"); - let specs = vec![DiffSpec { - path: "file".into(), - ..Default::default() - }]; - let outcome = commit_engine::create_commit( - &repo, - Destination::ParentForNewCommit(Some(repo.head_id()?.into())), - None, - specs.clone(), - "the file has no worktree changes even though we claim it - so it's rejected and no new commit is created", - CONTEXT_LINES, - RefHandling::UpdateHEADRefForTipCommits - )?; - assert_eq!( - outcome.new_commit, None, - "no new commit is returned as no change actually happened" - ); - insta::assert_debug_snapshot!(&outcome, @r#" - CreateCommitOutcome { - rejected_specs: [ - DiffSpec { - previous_path: None, - path: "file", - hunk_headers: [], - }, - ], - new_commit: None, - ref_edit: None, - } - "#); - Ok(()) - } -} - -mod utils { - use but_core::TreeStatus; - use but_workspace::commit_engine::{Destination, DiffSpec, RefHandling}; - use gix::prelude::ObjectIdExt; - use gix_testtools::Creation; - - pub const CONTEXT_LINES: u32 = 0; - - /// Returns an environment that assure commits are reproducible. This needs the `testing` feature enabled in `but-core` as well to work. - /// Note that this is racy once other tests rely on other values for these environment variables. - pub fn stable_env() -> gix_testtools::Env<'static> { - gix_testtools::Env::new() - .set("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000") - .set("GIT_AUTHOR_EMAIL", "author@example.com") - .set("GIT_AUTHOR_NAME", "author") - .set("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000") - .set("GIT_COMMITTER_EMAIL", "committer@example.com") - .set("GIT_COMMITTER_NAME", "committer") - .set("CHANGE_ID", "committer") - } - - fn writable_scenario_inner( - name: &str, - creation: Creation, - ) -> anyhow::Result<(gix::Repository, tempfile::TempDir)> { - let tmp = gix_testtools::scripted_fixture_writable_with_args( - format!("scenario/{name}.sh",), - None::, - creation, - ) - .map_err(anyhow::Error::from_boxed)?; - let mut options = gix::open::Options::isolated(); - options.permissions.env = gix::open::permissions::Environment::all(); - let repo = gix::open_opts(tmp.path(), options)?; - Ok((repo, tmp)) - } - - pub fn writable_scenario(name: &str) -> (gix::Repository, tempfile::TempDir) { - writable_scenario_inner(name, Creation::CopyFromReadOnly) - .expect("fixtures will yield valid repositories") - } - pub fn writable_scenario_execute(name: &str) -> (gix::Repository, tempfile::TempDir) { - writable_scenario_inner(name, Creation::ExecuteScript) - .expect("fixtures will yield valid repositories") - } - /// Always use all the hunks. - pub fn to_change_specs_whole_file(changes: but_core::WorktreeChanges) -> Vec { - let out: Vec<_> = changes - .changes - .into_iter() - .map(|change| DiffSpec { - previous_path: change.previous_path().map(ToOwned::to_owned), - path: change.path, - hunk_headers: Vec::new(), - }) - .collect(); - assert!( - !out.is_empty(), - "fixture should contain actual changes to turn into requests" - ); - out - } - - /// Always use all the hunks. - pub fn to_change_specs_all_hunks( - repo: &gix::Repository, - changes: but_core::WorktreeChanges, - ) -> anyhow::Result> { - to_change_specs_all_hunks_with_context_lines(repo, changes, CONTEXT_LINES) - } - - /// Always use all the hunks. - pub fn to_change_specs_all_hunks_with_context_lines( - repo: &gix::Repository, - changes: but_core::WorktreeChanges, - context_lines: u32, - ) -> anyhow::Result> { - let mut out = Vec::with_capacity(changes.changes.len()); - for change in changes.changes { - let spec = match change.status { - // Untracked files must always be taken from disk (they don't have a counterpart in a tree yet) - TreeStatus::Addition { is_untracked, .. } if is_untracked => DiffSpec { - path: change.path, - ..Default::default() - }, - _ => { - match change.unified_diff(repo, context_lines) { - Ok(but_core::UnifiedDiff::Patch { hunks }) => DiffSpec { - previous_path: change.previous_path().map(ToOwned::to_owned), - path: change.path, - hunk_headers: hunks.into_iter().map(Into::into).collect(), - }, - Ok(_) => unreachable!("tests won't be binary or too large"), - Err(_err) => { - // Assume it's a submodule or something without content, don't do hunks then. - DiffSpec { - path: change.path, - ..Default::default() - } - } - } - } - }; - out.push(spec); - } - Ok(out) - } - - pub fn write_sequence( - repo: &gix::Repository, - filename: &str, - sequences: impl IntoIterator>, impl Into>)>, - ) -> anyhow::Result<()> { - use std::fmt::Write; - let mut out = String::new(); - for (start, end) in sequences { - let (start, end) = match (start.into(), end.into()) { - (Some(start), Some(end)) => (start, end), - (Some(start), None) => (1, start), - invalid => panic!("invalid sequence: {invalid:?}"), - }; - for num in start..=end { - writeln!(&mut out, "{}", num)?; - } - } - std::fs::write( - repo.work_dir().expect("non-bare").join(filename), - out.as_bytes(), - )?; - Ok(()) - } - - pub fn visualize_tree( - repo: &gix::Repository, - outcome: &but_workspace::commit_engine::CreateCommitOutcome, - ) -> anyhow::Result { - Ok(gitbutler_testsupport::visualize_gix_tree( - outcome - .new_commit - .expect("no rejected changes") - .attach(repo) - .object()? - .peel_to_commit()? - .tree_id()?, - ) - .to_string()) - } - - /// Create a commit with the entire file as change, and another time with a whole hunk. - /// Both should be equal or it will panic. - pub fn commit_whole_files_and_all_hunks_from_workspace( - repo: &gix::Repository, - destination: Destination, - message: &str, - ) -> anyhow::Result { - let worktree_changes = but_core::diff::worktree_changes(repo)?; - let whole_file_output = but_workspace::commit_engine::create_commit( - repo, - destination, - None, - to_change_specs_whole_file(worktree_changes.clone()), - message, - CONTEXT_LINES, - RefHandling::None, - )?; - let all_hunks_output = but_workspace::commit_engine::create_commit( - repo, - destination, - None, - to_change_specs_all_hunks(repo, worktree_changes)?, - message, - CONTEXT_LINES, - RefHandling::UpdateHEADRefForTipCommits, - )?; - - if whole_file_output.new_commit.is_some() && all_hunks_output.new_commit.is_some() { - assert_eq!( - visualize_tree(repo, &all_hunks_output)?, - visualize_tree(repo, &whole_file_output)?, - ); - } - assert_eq!( - all_hunks_output.new_commit, whole_file_output.new_commit, - "Adding the whole file is the same as adding all patches (but whole files are faster)" - ); - // NOTE: cannot compare rejections as whole-file rejections don't have hunks - assert_eq!( - all_hunks_output - .rejected_specs - .iter() - .cloned() - .map(|mut spec| { - spec.hunk_headers.clear(); - spec - }) - .collect::>(), - whole_file_output.rejected_specs, - "rejections are the same as well" - ); - Ok(all_hunks_output) - } -} diff --git a/crates/but-workspace/tests/workspace/commit_engine/amend_commit.rs b/crates/but-workspace/tests/workspace/commit_engine/amend_commit.rs new file mode 100644 index 0000000000..3645f2cd98 --- /dev/null +++ b/crates/but-workspace/tests/workspace/commit_engine/amend_commit.rs @@ -0,0 +1,177 @@ +use crate::commit_engine::utils::{ + assure_stable_env, commit_from_outcome, commit_whole_files_and_all_hunks_from_workspace, + read_only_in_memory_scenario, visualize_tree, writable_scenario, writable_scenario_execute, + write_sequence, +}; +use but_workspace::commit_engine::Destination; + +#[test] +fn all_changes_and_renames_to_topmost_commit_no_parent() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("all-file-types-renamed-and-modified")?; + let head_commit = repo.rev_parse_single("HEAD")?; + insta::assert_snapshot!(gitbutler_testsupport::visualize_gix_tree(head_commit.object()?.peel_to_tree()?.id()), @r#" + 3fd29f0 + ├── executable:100755:01e79c3 "1\n2\n3\n" + ├── file:100644:3aac70f "5\n6\n7\n8\n" + └── link:120000:c4c364c "nonexisting-target" + "#); + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::AmendCommit(head_commit.into()), + )?; + insta::assert_debug_snapshot!(&outcome, @r" + CreateCommitOutcome { + rejected_specs: [], + new_commit: Some( + Sha1(aacf6391c96a59461df0a241caad4ad24368f542), + ), + } + "); + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 0236fb1 + ├── executable-renamed:100755:94ebaf9 "1\n2\n3\n4\n" + ├── file-renamed:100644:66f816c "5\n6\n7\n8\n9\n" + └── link-renamed:120000:94e4e07 "other-nonexisting-target" + "#); + insta::assert_debug_snapshot!(commit_from_outcome(&repo, &outcome)?, @r#" + Commit { + tree: Sha1(0236fb167942f3665aa348c514e8d272a6581ad5), + parents: [], + author: Signature { + name: "author", + email: "author@example.com", + time: Time { + seconds: 946684800, + offset: 0, + sign: Plus, + }, + }, + committer: Signature { + name: "committer", + email: "committer@example.com", + time: Time { + seconds: 946771200, + offset: 0, + sign: Plus, + }, + }, + encoding: None, + message: "init\n", + extra_headers: [], + } + "#); + Ok(()) +} + +#[test] +fn all_aspects_of_amended_commit_are_copied() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset"); + // Rewrite the entire file, which is fine as we rewrite/amend the base-commit itself. + write_sequence(&repo, "file", [(40, 70)])?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::AmendCommit(repo.rev_parse_single("merge")?.detach()), + )?; + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 5bbee6d + └── file:100644:1c9325b "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n" + "#); + insta::assert_debug_snapshot!(commit_from_outcome(&repo, &outcome)?, @r#" + Commit { + tree: Sha1(5bbee6d0219923e795f7b0818dda2f33f16278b4), + parents: [ + Sha1(91ef6f6fc0a8b97fb456886c1cc3b2a3536ea2eb), + Sha1(7f389eda1b366f3d56ecc1300b3835727c3309b6), + ], + author: Signature { + name: "author", + email: "author@example.com", + time: Time { + seconds: 946684800, + offset: 0, + sign: Plus, + }, + }, + committer: Signature { + name: "committer", + email: "committer@example.com", + time: Time { + seconds: 946771200, + offset: 0, + sign: Plus, + }, + }, + encoding: None, + message: "Merge branch \'A\' into merge\n", + extra_headers: [], + } + "#); + Ok(()) +} + +#[test] +fn signatures_are_redone() -> anyhow::Result<()> { + assure_stable_env(); + + let (mut repo, _tmp) = writable_scenario_execute("two-signed-commits-with-line-offset"); + + let head_id = repo.head_id()?; + let head_commit = head_id.object()?.into_commit().decode()?.to_owned(); + let head_id = head_id.detach(); + let previous_signature = head_commit + .extra_headers() + .pgp_signature() + .expect("it's signed by default"); + + // Rewrite everything for amending on top. + write_sequence(&repo, "file", [(40, 60)])?; + let outcome = + commit_whole_files_and_all_hunks_from_workspace(&repo, Destination::AmendCommit(head_id))?; + + let new_commit = commit_from_outcome(&repo, &outcome)?; + let new_signature = new_commit + .extra_headers() + .pgp_signature() + .expect("signing config is respected"); + assert_ne!( + previous_signature, new_signature, + "signatures are recreated as the commit is changed" + ); + assert_eq!( + new_commit + .extra_headers() + .find_all(gix::objs::commit::SIGNATURE_FIELD_NAME) + .count(), + 1, + "it doesn't leave outdated signatures on top of the updated one" + ); + + repo.config_snapshot_mut() + .set_raw_value(&"gitbutler.signCommits", "false")?; + write_local_config(&repo)?; + let outcome = + commit_whole_files_and_all_hunks_from_workspace(&repo, Destination::AmendCommit(head_id))?; + let new_commit = commit_from_outcome(&repo, &outcome)?; + assert!( + new_commit.extra_headers().pgp_signature().is_none(), + "If signing commits is disabled, \ + it will drop the signature (instead of leaving an invalid one)" + ); + Ok(()) +} + +// In-memory config changes aren't enough as we still only have snapshots, without the ability to keep +// the entire configuration fresh. +fn write_local_config(repo: &gix::Repository) -> anyhow::Result<()> { + repo.config_snapshot().write_to_filter( + &mut std::fs::File::create(repo.path().join("config"))?, + |section| section.meta().source == gix::config::Source::Local, + )?; + Ok(()) +} diff --git a/crates/but-workspace/tests/workspace/commit_engine/mod.rs b/crates/but-workspace/tests/workspace/commit_engine/mod.rs new file mode 100644 index 0000000000..1f6ad53e4f --- /dev/null +++ b/crates/but-workspace/tests/workspace/commit_engine/mod.rs @@ -0,0 +1,3 @@ +mod amend_commit; +mod new_commit; +mod utils; diff --git a/crates/but-workspace/tests/workspace/commit_engine/new_commit.rs b/crates/but-workspace/tests/workspace/commit_engine/new_commit.rs new file mode 100644 index 0000000000..5e13a90af6 --- /dev/null +++ b/crates/but-workspace/tests/workspace/commit_engine/new_commit.rs @@ -0,0 +1,677 @@ +use crate::commit_engine::utils::{ + assure_stable_env, commit_from_outcome, commit_whole_files_and_all_hunks_from_workspace, + read_only_in_memory_scenario, to_change_specs_all_hunks, + to_change_specs_all_hunks_with_context_lines, to_change_specs_whole_file, visualize_tree, + writable_scenario, writable_scenario_execute, write_sequence, CONTEXT_LINES, +}; +use but_workspace::commit_engine; +use but_workspace::commit_engine::{CreateCommitOutcome, Destination, DiffSpec}; +use gix::prelude::ObjectIdExt; + +#[test] +fn from_unborn_head() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("unborn-untracked"); + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: None, + message: "the commit message".into(), + }, + )?; + insta::assert_debug_snapshot!(&outcome, @r" + CreateCommitOutcome { + rejected_specs: [], + new_commit: Some( + Sha1(2eb2e90e37a7f23052db17b67b91eb5c4a7a1e81), + ), + } + "); + + let new_commit_id = outcome.new_commit.expect("a new commit was created"); + assert!( + repo.try_find_reference(repo.head_name()?.expect("not detached").as_ref())? + .is_none(), + "the HEAD reference isn't altered, so the repository stays unborn", + ); + + let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; + assert_eq!(new_commit.message_raw()?, "the commit message"); + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 861d6e2 + └── not-yet-tracked:100644:d95f3ad "content\n" + "#); + + std::fs::write( + repo.work_dir().expect("non-bare").join("new-untracked"), + "new-content", + )?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(new_commit_id), + message: "the second commit".into(), + }, + )?; + + insta::assert_debug_snapshot!(&outcome, @r" + CreateCommitOutcome { + rejected_specs: [], + new_commit: Some( + Sha1(9fa45065e99a2f0492bca947cf462dfafd905516), + ), + } + "); + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + a004469 + ├── new-untracked:100644:72278a7 "new-content" + └── not-yet-tracked:100644:d95f3ad "content\n" + "#); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn from_unborn_head_all_file_types() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("unborn-untracked-all-file-types")?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: None, + message: "the commit message".into(), + }, + )?; + + assert_eq!( + outcome.rejected_specs, + Vec::new(), + "everything was committed" + ); + let new_commit_id = outcome.new_commit.expect("a new commit was created"); + + let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; + assert_eq!(new_commit.message_raw()?, "the commit message"); + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 7f802e9 + ├── link:120000:faf96c1 "untracked" + ├── untracked:100644:d95f3ad "content\n" + └── untracked-exe:100755:86daf54 "exe\n" + "#); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn from_first_commit_all_file_types_changed() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("all-file-types-changed")?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("HEAD")?.into()), + message: "the commit message".into(), + }, + )?; + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 9be09ac + ├── soon-executable:100755:d95f3ad "content\n" + ├── soon-file-not-link:100644:72f007b "ordinary content\n" + └── soon-not-executable:100644:86daf54 "exe\n" + "#); + Ok(()) +} + +#[test] +fn unborn_with_added_submodules() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("unborn-with-submodules"); + let worktree_changes = but_core::diff::worktree_changes(&repo)?; + let outcome = but_workspace::commit_engine::create_commit( + &repo, + Destination::NewCommit { + parent_commit_id: None, + message: + "submodules have to be given as whole files but can then be handled correctly \ + (but without Git's special handling)" + .into(), + }, + None, + to_change_specs_whole_file(worktree_changes), + CONTEXT_LINES, + )?; + + assert_eq!( + outcome.rejected_specs, + vec![], + "Everything could be added to the repository" + ); + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 6260c86 + ├── .gitmodules:100644:49dc605 "[submodule \"m1\"]\n\tpath = m1\n\turl = ./module\n" + ├── m1:160000:a047f81 + └── module:160000:a047f81 + "#); + Ok(()) +} + +#[test] +fn deletions() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("delete-all-file-types")?; + let head_commit = repo.rev_parse_single("HEAD")?; + insta::assert_snapshot!(gitbutler_testsupport::visualize_gix_tree(head_commit.object()?.peel_to_tree()?.id()), @r#" + cecc2da + ├── .gitmodules:100644:51f8807 "[submodule \"submodule\"]\n\tpath = submodule\n\turl = ./embedded-repository\n" + ├── embedded-repository:160000:a047f81 + ├── executable:100755:86daf54 "exe\n" + ├── file-to-remain:100644:d95f3ad "content\n" + ├── link:120000:b158162 "file-to-remain" + └── submodule:160000:a047f81 + "#); + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(head_commit.into()), + message: "deletions maybe a bit special".into(), + }, + )?; + + insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" + c15318d + └── file-to-remain:100644:d95f3ad "content\n" + "#); + Ok(()) +} + +#[test] +fn renames() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("all-file-types-renamed-and-modified")?; + let head_commit = repo.rev_parse_single("HEAD")?; + insta::assert_snapshot!(gitbutler_testsupport::visualize_gix_tree(head_commit.object()?.peel_to_tree()?.id()), @r#" + 3fd29f0 + ├── executable:100755:01e79c3 "1\n2\n3\n" + ├── file:100644:3aac70f "5\n6\n7\n8\n" + └── link:120000:c4c364c "nonexisting-target" + "#); + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(head_commit.into()), + message: "renames need special care to delete the source".into(), + }, + )?; + + insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" + 0236fb1 + ├── executable-renamed:100755:94ebaf9 "1\n2\n3\n4\n" + ├── file-renamed:100644:66f816c "5\n6\n7\n8\n9\n" + └── link-renamed:120000:94e4e07 "other-nonexisting-target" + "#); + Ok(()) +} + +#[test] +fn submodule_typechanges() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("submodule-typechanges"); + let worktree_changes = but_core::diff::worktree_changes(&repo)?; + insta::assert_debug_snapshot!(worktree_changes.changes, @r#" + [ + TreeChange { + path: ".gitmodules", + status: Modification { + previous_state: ChangeState { + id: Sha1(51f8807c330e4ae8643ca943231cc6e176038aca), + kind: Blob, + }, + state: ChangeState { + id: Sha1(57fc33bc66d69e4df4ab23c33ae1101e67e56079), + kind: Blob, + }, + flags: None, + }, + }, + TreeChange { + path: "file", + status: Modification { + previous_state: ChangeState { + id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), + kind: Blob, + }, + state: ChangeState { + id: Sha1(a047f8183ba2bb7eb00ef89e60050c5fde740483), + kind: Commit, + }, + flags: Some( + TypeChange, + ), + }, + }, + TreeChange { + path: "submodule", + status: Modification { + previous_state: ChangeState { + id: Sha1(a047f8183ba2bb7eb00ef89e60050c5fde740483), + kind: Commit, + }, + state: ChangeState { + id: Sha1(d95f3ad14dee633a758d2e331151e950dd13e4ed), + kind: Blob, + }, + flags: Some( + TypeChange, + ), + }, + }, + ] + "#); + let outcome = but_workspace::commit_engine::create_commit( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("HEAD")?.into()), + message: + "submodules have to be given as whole files but can then be handled correctly \ + (but without Git's special handling)" + .into(), + }, + None, + to_change_specs_whole_file(worktree_changes), + CONTEXT_LINES, + )?; + + assert_eq!( + outcome.rejected_specs, + vec![], + "Everything could be added to the repository" + ); + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 05b8ed2 + ├── .gitmodules:100644:57fc33b "[submodule \"submodule\"]\n\tpath = file\n\turl = ./embedded-repository\n" + ├── embedded-repository:160000:a047f81 + ├── file:160000:a047f81 + └── submodule:100644:d95f3ad "content\n" + "#); + Ok(()) +} + +#[test] +fn commit_to_one_below_tip() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("two-commits-with-line-offset"); + write_sequence(&repo, "file", [(20, Some(40)), (80, None), (30, Some(50))])?; + let first_commit = Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("first-commit")?.into()), + message: "we apply a change with line offsets on top of the first commit, so the patch wouldn't apply cleanly.".into() + }; + + let outcome_ctx_0 = commit_whole_files_and_all_hunks_from_workspace(&repo, first_commit)?; + let tree = visualize_tree(&repo, &outcome_ctx_0)?; + insta::assert_snapshot!(tree, @r#" + 754a70c + └── file:100644:cc418b0 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n76\n77\n78\n79\n80\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" + "#); + Ok(()) +} + +#[test] +fn commit_to_one_below_tip_with_three_context_lines() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("two-commits-with-line-offset"); + write_sequence(&repo, "file", [(20, Some(40)), (80, None), (30, Some(50))])?; + for context_lines in [0, 3, 5] { + let first_commit = Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("first-commit")?.into()), + message: "When using context lines, we'd still think this works just like before" + .into(), + }; + + let outcome = but_workspace::commit_engine::create_commit( + &repo, + first_commit, + None, + to_change_specs_all_hunks_with_context_lines( + &repo, + but_core::diff::worktree_changes(&repo)?, + context_lines, + )?, + context_lines, + )?; + + assert_eq!( + outcome.new_commit.map(|id| id.to_string()), + Some("a33e9992196d88b09118158608acf4234b3273a9".to_string()) + ); + let tree = visualize_tree(&repo, &outcome)?; + assert_eq!( + tree, + r#"754a70c +└── file:100644:cc418b0 "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n76\n77\n78\n79\n80\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" +"# + ); + } + Ok(()) +} + +#[test] +fn commit_to_branches_below_merge_commit() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset"); + + write_sequence(&repo, "file", [(1, 20), (40, 50)])?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("B")?.into()), + message: "a new commit onto B, changing only the lines that it wrote".into(), + }, + )?; + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + a38c1c3 + └── file:100644:12121fe "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n" + "#); + + write_sequence(&repo, "file", [(40, 50), (10, 30)])?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.rev_parse_single("A")?.into()), + message: "a new commit onto A, changing only the lines that it wrote".into(), + }, + )?; + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 704f5ca + └── file:100644:bc33e02 "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" + "#); + Ok(()) +} + +#[test] +fn commit_whole_file_to_conflicting_position() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset"); + + // rewrite all lines so changes cover both branches + write_sequence(&repo, "file", [(40, 70)])?; + for conflicting_parent_commit in ["A", "B", "main"] { + let parent_commit = repo.rev_parse_single(conflicting_parent_commit)?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(parent_commit.into()), + message: "this commit can't be done as it covers multiple commits, \ + which will conflict on cherry-picking" + .into(), + }, + )?; + assert_eq!( + outcome, + CreateCommitOutcome { + rejected_specs: to_change_specs_all_hunks( + &repo, + but_core::diff::worktree_changes(&repo)? + )?, + new_commit: None, + }, + "It shouldn't produce a commit and clearly mark the conflicting specs" + ); + } + + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit{ + parent_commit_id: Some(repo.head_id()?.into()), + message: "but it can be applied directly to the tip, the merge commit itself, it always works".into() + }, + )?; + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 5bbee6d + └── file:100644:1c9325b "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n" + "#); + Ok(()) +} + +#[test] +fn commit_whole_file_to_conflicting_position_one_unconflicting_file_remains() -> anyhow::Result<()> +{ + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("merge-with-two-branches-line-offset-two-files"); + + // rewrite all lines so changes cover both branches + write_sequence(&repo, "file", [(40, 70)])?; + // Change the second file to be non-conflicting, just the half the lines in the middle + write_sequence(&repo, "other-file", [(35, 44), (80, 90), (66, 75)])?; + for conflicting_parent_commit in ["A", "B", "main"] { + let parent_commit = repo.rev_parse_single(conflicting_parent_commit)?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(parent_commit.into()), + message: "this commit can't be done as it covers multiple commits, \ + which will conflict on cherry-picking" + .into(), + }, + )?; + assert_eq!( + outcome.rejected_specs, + Vec::from_iter( + to_change_specs_all_hunks(&repo, but_core::diff::worktree_changes(&repo)?)? + .first() + .cloned() + ), + "It still produces a commit as one file was non-conflicting, keeping the base version of the non-conflicting file" + ); + // Different bases mean different base versions for the conflicting file. + if conflicting_parent_commit == "A" { + insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" + 0816d13 + ├── file:100644:0ff3bbb "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" + └── other-file:100644:593469b "35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n" + "#); + } else if conflicting_parent_commit == "B" { + insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" + df6d629 + ├── file:100644:1f1542b "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n" + └── other-file:100644:a935ec9 "80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n" + "#); + } else if conflicting_parent_commit == "main" { + insta::assert_snapshot!(visualize_tree(&repo, &outcome)?, @r#" + d5d6e30 + ├── file:100644:e33f5e9 "10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n" + └── other-file:100644:240fe08 "80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n" + "#); + } + } + + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.head_id()?.into()), + message: "but it can be applied directly to the tip, \ + the merge commit itself, it always works" + .into(), + }, + )?; + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + 7d017dd + ├── file:100644:1c9325b "40\n41\n42\n43\n44\n45\n46\n47\n48\n49\n50\n51\n52\n53\n54\n55\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n69\n70\n" + └── other-file:100644:4223e57 "35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n80\n81\n82\n83\n84\n85\n86\n87\n88\n89\n90\n66\n67\n68\n69\n70\n71\n72\n73\n74\n75\n" + "#); + Ok(()) +} + +#[test] +fn unborn_untracked_worktree_filters_are_applied_to_whole_files() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario("unborn-untracked-crlf"); + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: None, + message: "the commit message".into(), + }, + )?; + insta::assert_debug_snapshot!(&outcome, @r" + CreateCommitOutcome { + rejected_specs: [], + new_commit: Some( + Sha1(f45994afa0d26558ae4bea626917b70f8863a29b), + ), + } + "); + + let new_commit_id = outcome.new_commit.expect("a new commit was created"); + let new_commit = new_commit_id.attach(&repo).object()?.peel_to_commit()?; + assert_eq!(new_commit.message_raw()?, "the commit message"); + + // What's in Git is unix style newlines + let tree = gitbutler_testsupport::visualize_gix_tree(new_commit.tree_id()?); + insta::assert_snapshot!(tree, @r#" + d5949f1 + └── not-yet-tracked:100644:1191247 "1\n2\n" + "#); + + std::fs::write( + repo.work_dir().expect("non-bare").join("new-untracked"), + "one\r\ntwo\r\n", + )?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(new_commit_id), + message: "the second commit".into(), + }, + )?; + + insta::assert_debug_snapshot!(&outcome, @r" + CreateCommitOutcome { + rejected_specs: [], + new_commit: Some( + Sha1(9218f64284f5b8f31c42aed238ec89ff1836a253), + ), + } + "); + + let tree = visualize_tree(&repo, &outcome)?; + insta::assert_snapshot!(tree, @r#" + cef7412 + ├── new-untracked:100644:814f4a4 "one\ntwo\n" + └── not-yet-tracked:100644:1191247 "1\n2\n" + "#); + + Ok(()) +} + +#[test] +fn signatures_are_redone() -> anyhow::Result<()> { + assure_stable_env(); + + let (repo, _tmp) = writable_scenario_execute("two-signed-commits-with-line-offset"); + + let head_id = repo.head_id()?; + let head_commit = head_id.object()?.into_commit().decode()?.to_owned(); + let head_id = head_id.detach(); + let previous_signature = head_commit + .extra_headers() + .pgp_signature() + .expect("it's signed by default"); + + // Rewrite everything for amending on top. + write_sequence(&repo, "file", [(40, 60)])?; + let outcome = commit_whole_files_and_all_hunks_from_workspace( + &repo, + Destination::NewCommit { + parent_commit_id: Some(head_id), + message: "a commit with signature".into(), + }, + )?; + + let new_commit = commit_from_outcome(&repo, &outcome)?; + let new_signature = new_commit + .extra_headers() + .pgp_signature() + .expect("signing config is respected"); + assert_ne!( + previous_signature, new_signature, + "signatures are recreated as the commit is changed" + ); + assert_eq!( + new_commit + .extra_headers() + .find_all(gix::objs::commit::SIGNATURE_FIELD_NAME) + .count(), + 1, + "it doesn't leave outdated signatures on top of the updated one" + ); + Ok(()) +} + +#[test] +fn validate_no_change_on_noop() -> anyhow::Result<()> { + assure_stable_env(); + + let repo = read_only_in_memory_scenario("two-commits-with-line-offset")?; + let specs = vec![DiffSpec { + path: "file".into(), + ..Default::default() + }]; + let outcome = commit_engine::create_commit( + &repo, + Destination::NewCommit { + parent_commit_id: Some(repo.head_id()?.into()), + message: "the file has no worktree changes even though we claim it - \ + so it's rejected and no new commit is created" + .into(), + }, + None, + specs.clone(), + CONTEXT_LINES, + )?; + assert_eq!( + outcome.new_commit, None, + "no new commit is returned as no change actually happened" + ); + insta::assert_debug_snapshot!(&outcome, @r#" + CreateCommitOutcome { + rejected_specs: [ + DiffSpec { + previous_path: None, + path: "file", + hunk_headers: [], + }, + ], + new_commit: None, + } + "#); + Ok(()) +} diff --git a/crates/but-workspace/tests/workspace/commit_engine/utils.rs b/crates/but-workspace/tests/workspace/commit_engine/utils.rs new file mode 100644 index 0000000000..c0467ad504 --- /dev/null +++ b/crates/but-workspace/tests/workspace/commit_engine/utils.rs @@ -0,0 +1,224 @@ +use but_core::TreeStatus; +use but_workspace::commit_engine::{Destination, DiffSpec}; +use gix::prelude::ObjectIdExt; +use gix_testtools::Creation; + +pub const CONTEXT_LINES: u32 = 0; + +/// Sets and environment that assures commits are reproducible. +/// This needs the `testing` feature enabled in `but-core` as well to work. +/// This changes the process environment, be aware. +pub fn assure_stable_env() { + let env = gix_testtools::Env::new() + .set("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000") + .set("GIT_AUTHOR_EMAIL", "author@example.com") + .set("GIT_AUTHOR_NAME", "author") + .set("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000") + .set("GIT_COMMITTER_EMAIL", "committer@example.com") + .set("GIT_COMMITTER_NAME", "committer") + .set("CHANGE_ID", "committer"); + // assure it doesn't get racy. + std::mem::forget(env); +} + +fn writable_scenario_inner( + name: &str, + creation: Creation, +) -> anyhow::Result<(gix::Repository, tempfile::TempDir)> { + let tmp = gix_testtools::scripted_fixture_writable_with_args( + format!("scenario/{name}.sh"), + None::, + creation, + ) + .map_err(anyhow::Error::from_boxed)?; + let mut options = gix::open::Options::isolated(); + options.permissions.env = gix::open::permissions::Environment::all(); + let repo = gix::open_opts(tmp.path(), options)?; + Ok((repo, tmp)) +} + +/// Provide a scenario but assure the returned repository will write objects to memory. +pub fn read_only_in_memory_scenario(name: &str) -> anyhow::Result { + let root = gix_testtools::scripted_fixture_read_only(format!("scenario/{name}.sh")) + .map_err(anyhow::Error::from_boxed)?; + let mut options = gix::open::Options::isolated(); + options.permissions.env = gix::open::permissions::Environment::all(); + let repo = gix::open_opts(root, options)?.with_object_memory(); + Ok(repo) +} + +pub fn writable_scenario(name: &str) -> (gix::Repository, tempfile::TempDir) { + writable_scenario_inner(name, Creation::CopyFromReadOnly) + .expect("fixtures will yield valid repositories") +} + +pub fn writable_scenario_execute(name: &str) -> (gix::Repository, tempfile::TempDir) { + writable_scenario_inner(name, Creation::ExecuteScript) + .expect("fixtures will yield valid repositories") +} + +/// Always use all the hunks. +pub fn to_change_specs_whole_file(changes: but_core::WorktreeChanges) -> Vec { + let out: Vec<_> = changes + .changes + .into_iter() + .map(|change| DiffSpec { + previous_path: change.previous_path().map(ToOwned::to_owned), + path: change.path, + hunk_headers: Vec::new(), + }) + .collect(); + assert!( + !out.is_empty(), + "fixture should contain actual changes to turn into requests" + ); + out +} + +/// Always use all the hunks. +pub fn to_change_specs_all_hunks( + repo: &gix::Repository, + changes: but_core::WorktreeChanges, +) -> anyhow::Result> { + to_change_specs_all_hunks_with_context_lines(repo, changes, CONTEXT_LINES) +} + +/// Always use all the hunks. +pub fn to_change_specs_all_hunks_with_context_lines( + repo: &gix::Repository, + changes: but_core::WorktreeChanges, + context_lines: u32, +) -> anyhow::Result> { + let mut out = Vec::with_capacity(changes.changes.len()); + for change in changes.changes { + let spec = match change.status { + // Untracked files must always be taken from disk (they don't have a counterpart in a tree yet) + TreeStatus::Addition { is_untracked, .. } if is_untracked => DiffSpec { + path: change.path, + ..Default::default() + }, + _ => { + match change.unified_diff(repo, context_lines) { + Ok(but_core::UnifiedDiff::Patch { hunks }) => DiffSpec { + previous_path: change.previous_path().map(ToOwned::to_owned), + path: change.path, + hunk_headers: hunks.into_iter().map(Into::into).collect(), + }, + Ok(_) => unreachable!("tests won't be binary or too large"), + Err(_err) => { + // Assume it's a submodule or something without content, don't do hunks then. + DiffSpec { + path: change.path, + ..Default::default() + } + } + } + } + }; + out.push(spec); + } + Ok(out) +} + +pub fn write_sequence( + repo: &gix::Repository, + filename: &str, + sequences: impl IntoIterator>, impl Into>)>, +) -> anyhow::Result<()> { + use std::fmt::Write; + let mut out = String::new(); + for (start, end) in sequences { + let (start, end) = match (start.into(), end.into()) { + (Some(start), Some(end)) => (start, end), + (Some(start), None) => (1, start), + invalid => panic!("invalid sequence: {invalid:?}"), + }; + for num in start..=end { + writeln!(&mut out, "{}", num)?; + } + } + std::fs::write( + repo.work_dir().expect("non-bare").join(filename), + out.as_bytes(), + )?; + Ok(()) +} + +pub fn visualize_tree( + repo: &gix::Repository, + outcome: &but_workspace::commit_engine::CreateCommitOutcome, +) -> anyhow::Result { + Ok(gitbutler_testsupport::visualize_gix_tree( + outcome + .new_commit + .expect("no rejected changes") + .attach(repo) + .object()? + .peel_to_commit()? + .tree_id()?, + ) + .to_string()) +} + +/// Create a commit with the entire file as change, and another time with a whole hunk. +/// Both should be equal or it will panic. +pub fn commit_whole_files_and_all_hunks_from_workspace( + repo: &gix::Repository, + destination: Destination, +) -> anyhow::Result { + let worktree_changes = but_core::diff::worktree_changes(repo)?; + let whole_file_output = but_workspace::commit_engine::create_commit( + repo, + destination.clone(), + None, + to_change_specs_whole_file(worktree_changes.clone()), + CONTEXT_LINES, + )?; + let all_hunks_output = but_workspace::commit_engine::create_commit( + repo, + destination, + None, + to_change_specs_all_hunks(repo, worktree_changes)?, + CONTEXT_LINES, + )?; + + if whole_file_output.new_commit.is_some() && all_hunks_output.new_commit.is_some() { + assert_eq!( + visualize_tree(repo, &all_hunks_output)?, + visualize_tree(repo, &whole_file_output)?, + ); + } + assert_eq!( + all_hunks_output.new_commit, whole_file_output.new_commit, + "Adding the whole file is the same as adding all patches (but whole files are faster)" + ); + // NOTE: cannot compare rejections as whole-file rejections don't have hunks + assert_eq!( + all_hunks_output + .rejected_specs + .iter() + .cloned() + .map(|mut spec| { + spec.hunk_headers.clear(); + spec + }) + .collect::>(), + whole_file_output.rejected_specs, + "rejections are the same as well" + ); + Ok(all_hunks_output) +} + +pub fn commit_from_outcome( + repo: &gix::Repository, + outcome: &but_workspace::commit_engine::CreateCommitOutcome, +) -> anyhow::Result { + Ok(outcome + .new_commit + .expect("the amended commit was created") + .attach(repo) + .object()? + .peel_to_commit()? + .decode()? + .into()) +} diff --git a/crates/gitbutler-tauri/src/workspace.rs b/crates/gitbutler-tauri/src/workspace.rs index e2846a1658..1b7122d873 100644 --- a/crates/gitbutler-tauri/src/workspace.rs +++ b/crates/gitbutler-tauri/src/workspace.rs @@ -3,7 +3,6 @@ use crate::from_json::HexHash; use but_hunk_dependency::ui::{ hunk_dependencies_for_workspace_changes_by_worktree_dir, HunkDependencies, }; -use but_workspace::commit_engine::RefHandling; use but_workspace::{commit_engine, StackEntry}; use gitbutler_command_context::CommandContext; use gitbutler_project as projects; @@ -69,12 +68,13 @@ pub fn create_commit_from_worktree_changes( let repo = gix::open(project.worktree_path()).map_err(anyhow::Error::from)?; let out = commit_engine::create_commit( &repo, - commit_engine::Destination::ParentForNewCommit(parent_id.map(Into::into)), + commit_engine::Destination::NewCommit { + parent_commit_id: parent_id.map(Into::into), + message, + }, None, worktree_changes.into_iter().map(Into::into).collect(), - &message, 3, /* context-lines */ - RefHandling::UpdateHEADRefForTipCommits, )?; Ok(out.into()) }