update pull request
This commit is contained in:
Generated
+69
-87
@@ -14,38 +14,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_atspi_common"
|
name = "accesskit_atspi_common"
|
||||||
version = "0.18.1"
|
version = "0.19.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.36.0",
|
"accesskit_consumer",
|
||||||
"atspi-common",
|
"atspi-common",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "accesskit_consumer"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "accesskit_consumer"
|
|
||||||
version = "0.37.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_consumer"
|
name = "accesskit_consumer"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.38.0",
|
"accesskit_consumer",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
@@ -72,9 +52,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_unix"
|
name = "accesskit_unix"
|
||||||
version = "0.21.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_atspi_common",
|
"accesskit_atspi_common",
|
||||||
@@ -90,12 +70,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_windows"
|
name = "accesskit_windows"
|
||||||
version = "0.33.1"
|
version = "0.34.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
|
checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.37.0",
|
"accesskit_consumer",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
@@ -342,9 +322,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.43"
|
version = "0.4.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -678,9 +658,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.71.1"
|
version = "0.72.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
@@ -1238,7 +1218,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "collections"
|
name = "collections"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui_util",
|
"gpui_util",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -1263,9 +1243,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.38"
|
version = "0.4.39"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1275,9 +1255,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-core"
|
name = "compression-core"
|
||||||
version = "0.4.32"
|
version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
@@ -1704,7 +1684,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_refineable"
|
name = "derive_refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1866,7 +1846,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.4+spec-1.1.0",
|
"toml 1.1.5+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -3484,7 +3464,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3547,7 +3527,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"waker-fn",
|
"waker-fn",
|
||||||
"web-time",
|
"web-time",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
"zed-font-kit",
|
"zed-font-kit",
|
||||||
"zed-scap",
|
"zed-scap",
|
||||||
"ztracing",
|
"ztracing",
|
||||||
@@ -3556,7 +3536,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-base"
|
name = "gpui-base"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3592,7 +3572,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component"
|
name = "gpui-component"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3669,7 +3649,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-assets"
|
name = "gpui-component-assets"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3683,7 +3663,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-macros"
|
name = "gpui-component-macros"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3693,21 +3673,21 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-fps"
|
name = "gpui-fps"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
"instant",
|
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"sysinfo 0.37.2",
|
"sysinfo 0.37.2",
|
||||||
|
"web-time",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_apple"
|
name = "gpui_apple"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"block",
|
"block",
|
||||||
@@ -3730,7 +3710,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_linux"
|
name = "gpui_linux"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_unix",
|
"accesskit_unix",
|
||||||
@@ -3776,7 +3756,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macos"
|
name = "gpui_macos"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_macos",
|
"accesskit_macos",
|
||||||
@@ -3822,7 +3802,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macros"
|
name = "gpui_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3833,7 +3813,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_platform"
|
name = "gpui_platform"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3846,7 +3826,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_shared_string"
|
name = "gpui_shared_string"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3856,7 +3836,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_util"
|
name = "gpui_util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"log",
|
"log",
|
||||||
@@ -3866,7 +3846,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_web"
|
name = "gpui_web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
@@ -3890,7 +3870,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_wgpu"
|
name = "gpui_wgpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -3916,7 +3896,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_windows"
|
name = "gpui_windows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_windows",
|
"accesskit_windows",
|
||||||
@@ -3935,10 +3915,10 @@ dependencies = [
|
|||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.62.2",
|
||||||
"windows-numerics 0.2.0",
|
"windows-numerics 0.3.1",
|
||||||
"windows-registry 0.5.3",
|
"windows-registry 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4230,7 +4210,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client"
|
name = "http_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -4250,7 +4230,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client_tls"
|
name = "http_client_tls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -5034,9 +5014,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.3"
|
version = "0.18.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -5206,7 +5186,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "media"
|
name = "media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -5300,9 +5280,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -6119,9 +6099,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.4.2"
|
version = "5.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55"
|
checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6264,7 +6244,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "perf"
|
name = "perf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7052,7 +7032,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "refineable"
|
name = "refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_refineable",
|
"derive_refineable",
|
||||||
]
|
]
|
||||||
@@ -7135,7 +7115,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest_client"
|
name = "reqwest_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7513,7 +7493,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "scheduler"
|
name = "scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
@@ -7969,9 +7949,11 @@ dependencies = [
|
|||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"settings",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"signed_git",
|
"signed_git",
|
||||||
"signed_nostr",
|
"signed_nostr",
|
||||||
|
"tempfile",
|
||||||
"utils",
|
"utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8227,7 +8209,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sum_tree"
|
name = "sum_tree"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heapless 0.9.3",
|
"heapless 0.9.3",
|
||||||
"log",
|
"log",
|
||||||
@@ -8813,9 +8795,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.4+spec-1.1.0"
|
version = "1.1.5+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -9625,7 +9607,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util_macros"
|
name = "util_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"perf",
|
"perf",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -10505,13 +10487,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.5.3"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11394,7 +11376,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zlog"
|
name = "zlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -11411,7 +11393,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing"
|
name = "ztracing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -11422,7 +11404,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing_macro"
|
name = "ztracing_macro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
|
|||||||
@@ -107,6 +107,40 @@ impl Default for LocalReposSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A remembered association between a local checkout folder and an
|
||||||
|
/// announced repository. Recorded when the user clones a repository from
|
||||||
|
/// the app or picks a folder in the New PR panel, so the panel can prefill
|
||||||
|
/// the folder later without asking again.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CheckoutRecord {
|
||||||
|
/// Local folder of the checkout.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Repository address (`30617:<pubkey>:<id>`) as a string.
|
||||||
|
pub addr: String,
|
||||||
|
/// Unix seconds of the last use, for freshest-first ordering.
|
||||||
|
pub last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CheckoutRecord {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
path: PathBuf::new(),
|
||||||
|
addr: String::new(),
|
||||||
|
last_used: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remembered local checkouts (see [`CheckoutRecord`]).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CheckoutsSettings {
|
||||||
|
/// The remembered records; the latest use of a path+repo pair replaces
|
||||||
|
/// the older record.
|
||||||
|
pub records: Vec<CheckoutRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The create-repository dialog.
|
/// The create-repository dialog.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -128,6 +162,8 @@ pub struct Settings {
|
|||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
/// Local repository scanning.
|
/// Local repository scanning.
|
||||||
pub local_repos: LocalReposSettings,
|
pub local_repos: LocalReposSettings,
|
||||||
|
/// Remembered local checkouts.
|
||||||
|
pub checkouts: CheckoutsSettings,
|
||||||
/// The create-repository dialog.
|
/// The create-repository dialog.
|
||||||
pub create_repository: CreateRepositorySettings,
|
pub create_repository: CreateRepositorySettings,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,21 @@ impl Announcement {
|
|||||||
crate::repo_addr(self.owner, self.id.clone())
|
crate::repo_addr(self.owner, self.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this announcement is a fork of the repository at `base`:
|
||||||
|
/// its `u` tag points at `base` (also covers permanent forks whose EUC
|
||||||
|
/// diverged), or it shares `base`'s earliest unique commit (EUC) and is
|
||||||
|
/// not the base repository itself. Read-only discovery input: nothing
|
||||||
|
/// here is published back to nostr.
|
||||||
|
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||||
|
if self.addr() == *base {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||||
|
}
|
||||||
|
|
||||||
/// The description of the repository, or a default if none is provided.
|
/// The description of the repository, or a default if none is provided.
|
||||||
pub fn description(&self) -> SharedString {
|
pub fn description(&self) -> SharedString {
|
||||||
self.description
|
self.description
|
||||||
@@ -491,6 +506,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||||
|
// The base repository (announced by the `u`-tag's owner).
|
||||||
|
let base = crate::repo_addr(
|
||||||
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
|
"upstream",
|
||||||
|
);
|
||||||
|
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||||
|
let fork = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
// A `u` tag pointing at the base address marks a fork even when
|
||||||
|
// neither side announces an EUC.
|
||||||
|
assert!(fork.is_fork_of(&base, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_matches_a_shared_euc() {
|
||||||
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
// The base repo has no `u` tag; it announces the family EUC.
|
||||||
|
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||||
|
let base = Announcement::from_event(&base_event).expect("parses");
|
||||||
|
let base_addr = base.addr();
|
||||||
|
|
||||||
|
// A fork (no `u` tag; a pure mirror or cross-hosted clone) shares
|
||||||
|
// the EUC, so clients of the family can find it.
|
||||||
|
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||||
|
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||||
|
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
|
// An unrelated repository with a different EUC is not a fork.
|
||||||
|
let other_event = announcement_event(&[
|
||||||
|
&["d", "other"],
|
||||||
|
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
|
]);
|
||||||
|
let other = Announcement::from_event(&other_event).expect("parses");
|
||||||
|
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
|
// Without a base EUC there is nothing to compare against.
|
||||||
|
assert!(!fork.is_fork_of(&base_addr, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||||
|
// A permanent fork re-announces its EUC (first commit after the
|
||||||
|
// fork); only the `u` tag still relates it to the base.
|
||||||
|
let base = crate::repo_addr(
|
||||||
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
|
"upstream",
|
||||||
|
);
|
||||||
|
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&["u", &base.to_string()],
|
||||||
|
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
|
]);
|
||||||
|
let fork = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_excludes_the_base_itself() {
|
||||||
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||||
|
let base = Announcement::from_event(&event).expect("parses");
|
||||||
|
let base_addr = base.addr();
|
||||||
|
|
||||||
|
// The base announcement matches its own EUC, but is not a fork of
|
||||||
|
// itself.
|
||||||
|
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ impl GitCache {
|
|||||||
Self { root }
|
Self { root }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The root directory holding the mirror clones.
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
/// Local path of the clone for a repository.
|
/// Local path of the clone for a repository.
|
||||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||||
self.root
|
self.root
|
||||||
@@ -523,6 +528,132 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork/<owner>/<id>/*`) into the
|
||||||
|
/// repository at `repo_path` from the first working URL in `urls`, like
|
||||||
|
/// [`clone_repo`]: `grasp://` URLs are rewritten to `https://`, the
|
||||||
|
/// terminal prompt is disabled, and when no URL works the last error is
|
||||||
|
/// returned. Never touches the checked-out refs or the worktree.
|
||||||
|
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
let url = url
|
||||||
|
.strip_prefix("grasp://")
|
||||||
|
.map(|rest| format!("https://{rest}"))
|
||||||
|
.unwrap_or_else(|| url.to_owned());
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo_path)
|
||||||
|
.args(["fetch"])
|
||||||
|
.arg(&url)
|
||||||
|
.arg(refspec)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.context("failed to spawn `git fetch`")?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"git fetch from {url} failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_err {
|
||||||
|
Some(e) => Err(e).context("failed to fetch from any mirror"),
|
||||||
|
None => bail!("no clone URLs provided"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full ref names under `prefix` (e.g. `refs/fork/<owner>/<id>`), sorted
|
||||||
|
/// lexicographically, like `git for-each-ref`. An empty list when nothing
|
||||||
|
/// matches.
|
||||||
|
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||||
|
// `for-each-ref` patterns match whole path components, so a trailing
|
||||||
|
// slash would silently change what is matched.
|
||||||
|
let pattern = prefix.trim_end_matches('/');
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo_path)
|
||||||
|
.args(["for-each-ref", "--format=%(refname)", pattern])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.context("failed to spawn `git for-each-ref`")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git for-each-ref failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every ref under `prefix` (e.g. `refs/fork/<owner>/<id>`) of the
|
||||||
|
/// repository at `repo_path`, so a stale import can be pruned before a
|
||||||
|
/// re-import. No-op when nothing matches.
|
||||||
|
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||||
|
let refs = refs_with_prefix(repo_path, prefix)?;
|
||||||
|
if refs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo_path)
|
||||||
|
.args(["update-ref", "--stdin"])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn `git update-ref --stdin`")?;
|
||||||
|
|
||||||
|
for name in refs {
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.expect("stdin piped")
|
||||||
|
.write_all(format!("delete {name}\n").as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git update-ref failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL of the `origin` remote of the repository at `workdir`, or `None`
|
||||||
|
/// when it has no `origin` yet.
|
||||||
|
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(workdir)
|
||||||
|
.args(["remote", "get-url", "origin"])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.context("failed to spawn `git remote get-url`")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let url = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Ok((!url.trim().is_empty()).then(|| url.trim().to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt
|
/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt
|
||||||
/// is disabled so a credential request fails instead of hanging.
|
/// is disabled so a credential request fails instead of hanging.
|
||||||
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
||||||
@@ -2321,6 +2452,154 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origin_url_reads_the_remote_or_reports_none() {
|
||||||
|
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&_repo, "initial");
|
||||||
|
let dir = dir.path();
|
||||||
|
|
||||||
|
// No remote configured yet.
|
||||||
|
assert_eq!(origin_url(dir).expect("read"), None);
|
||||||
|
|
||||||
|
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
|
||||||
|
assert_eq!(
|
||||||
|
origin_url(dir).expect("read").as_deref(),
|
||||||
|
Some("https://gitnostr.com/npub1test/repo.git")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
||||||
|
// A bare "base" server holding the initial commit, like a grasp
|
||||||
|
// server's `{base}/{owner}/{repo-id}.git` layout.
|
||||||
|
let base_server = dir.path().join("npub1base").join("base.git");
|
||||||
|
std::fs::create_dir_all(base_server.parent().unwrap()).unwrap();
|
||||||
|
let init_status = Command::new("git")
|
||||||
|
.args(["init", "--bare", "-q"])
|
||||||
|
.arg(&base_server)
|
||||||
|
.status()
|
||||||
|
.expect("spawn git init --bare");
|
||||||
|
assert!(init_status.success());
|
||||||
|
|
||||||
|
let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&upstream_repo, "initial");
|
||||||
|
let upstream_path = upstream_dir.path();
|
||||||
|
let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial");
|
||||||
|
push_all(
|
||||||
|
upstream_path,
|
||||||
|
&format!("file://{}", dir.path().display()),
|
||||||
|
"npub1base",
|
||||||
|
"base",
|
||||||
|
)
|
||||||
|
.expect("push");
|
||||||
|
|
||||||
|
// The base mirror: a plain clone of the base server.
|
||||||
|
let base_url = format!("file://{}", base_server.display());
|
||||||
|
let mirror = dir.path().join("mirror");
|
||||||
|
git_run(
|
||||||
|
dir.path(),
|
||||||
|
&["clone", "-q", &base_url, mirror.to_str().unwrap()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The fork server: the same initial commit plus a feature commit on
|
||||||
|
// its own `feature` branch.
|
||||||
|
let fork_work = dir.path().join("fork-work");
|
||||||
|
git_run(
|
||||||
|
dir.path(),
|
||||||
|
&["clone", "-q", &base_url, fork_work.to_str().unwrap()],
|
||||||
|
);
|
||||||
|
git_run(&fork_work, &["checkout", "-b", "feature"]);
|
||||||
|
std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write");
|
||||||
|
commit_all(&gix::open(&fork_work).expect("open"), "feature commit");
|
||||||
|
let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip");
|
||||||
|
|
||||||
|
let fork_server = dir.path().join("npub1fork").join("fork.git");
|
||||||
|
std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap();
|
||||||
|
let init_status = Command::new("git")
|
||||||
|
.args(["init", "--bare", "-q"])
|
||||||
|
.arg(&fork_server)
|
||||||
|
.status()
|
||||||
|
.expect("spawn git init --bare");
|
||||||
|
assert!(init_status.success());
|
||||||
|
push_commit_ref(
|
||||||
|
&fork_work,
|
||||||
|
&format!("file://{}", fork_server.display()),
|
||||||
|
&tip,
|
||||||
|
"refs/heads/feature",
|
||||||
|
)
|
||||||
|
.expect("push");
|
||||||
|
|
||||||
|
// Import the fork's heads into the mirror under a private prefix;
|
||||||
|
// the first (dead) URL is skipped, the second works.
|
||||||
|
let dead = format!("file://{}/missing.git", dir.path().display());
|
||||||
|
fetch_repo_refs(
|
||||||
|
&mirror,
|
||||||
|
&[dead, format!("file://{}", fork_server.display())],
|
||||||
|
"+refs/heads/*:refs/fork/npub1fork/fork/*",
|
||||||
|
)
|
||||||
|
.expect("fetch");
|
||||||
|
|
||||||
|
// The imported refs are listed under the prefix only.
|
||||||
|
assert_eq!(
|
||||||
|
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
|
||||||
|
vec!["refs/fork/npub1fork/fork/feature"]
|
||||||
|
);
|
||||||
|
// Nothing leaked into the normal ref namespaces.
|
||||||
|
assert_eq!(
|
||||||
|
refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"),
|
||||||
|
Vec::<String>::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mirror can now range across both histories: the fork point is
|
||||||
|
// the shared initial commit, and the proposal covers the fork commit.
|
||||||
|
assert_eq!(
|
||||||
|
merge_base(
|
||||||
|
&mirror,
|
||||||
|
"refs/remotes/origin/main",
|
||||||
|
"refs/fork/npub1fork/fork/feature",
|
||||||
|
)
|
||||||
|
.expect("merge base")
|
||||||
|
.as_deref(),
|
||||||
|
Some(initial.as_str())
|
||||||
|
);
|
||||||
|
let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature")
|
||||||
|
.expect("patch");
|
||||||
|
assert!(patch.contains("Subject: [PATCH] feature commit"));
|
||||||
|
assert!(patch.contains("feature.txt"));
|
||||||
|
|
||||||
|
// Pruning the prefix removes the import again.
|
||||||
|
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
|
||||||
|
assert_eq!(
|
||||||
|
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
|
||||||
|
Vec::<String>::new()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_repo_refs_fails_when_every_url_fails() {
|
||||||
|
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
let dir = _dir.path();
|
||||||
|
|
||||||
|
let dead = format!("file://{}/missing.git", dir.display());
|
||||||
|
let err = fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*")
|
||||||
|
.expect_err("all URLs fail");
|
||||||
|
assert!(err.to_string().contains("failed to fetch"));
|
||||||
|
|
||||||
|
// Without any URL there is nothing to try.
|
||||||
|
let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
|
||||||
|
assert!(err.to_string().contains("no clone URLs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_refs_with_prefix_is_a_noop_without_matches() {
|
||||||
|
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a git command in `dir`, asserting success.
|
/// Run a git command in `dir`, asserting success.
|
||||||
fn git_run(dir: &Path, args: &[&str]) {
|
fn git_run(dir: &Path, args: &[&str]) {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
signed_git = { path = "../signed_git" }
|
signed_git = { path = "../signed_git" }
|
||||||
signed_nostr = { path = "../signed_nostr" }
|
signed_nostr = { path = "../signed_nostr" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
@@ -24,3 +25,6 @@ log.workspace = true
|
|||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1546,6 +1546,70 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url>
|
|||||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The GRASP-06 contributor namespace URL of a pull request tip on the
|
||||||
|
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in
|
||||||
|
/// the URL; the server stores it under the hex form). Anyone may push there;
|
||||||
|
/// no announcement or maintainer rights are involved.
|
||||||
|
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||||
|
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble the `clone` URLs of a pull request: the author's GRASP-06
|
||||||
|
/// `/prs/` URLs first (author-controlled, most likely to accept the tip
|
||||||
|
/// push), then the base announcement's clone URLs, deduplicated while
|
||||||
|
/// preserving that order.
|
||||||
|
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
for url in prs_urls.into_iter().chain(base_clone_urls) {
|
||||||
|
if seen.insert(url.to_string()) {
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urls
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
|
||||||
|
/// Unparseable URLs are dropped (the UI only writes well-formed servers).
|
||||||
|
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| tag.kind() == "g")
|
||||||
|
.filter_map(|tag| tag.content())
|
||||||
|
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grasp servers of the newest kind-10317 grasp list among `events`
|
||||||
|
/// (latest event wins, like every other latest-wins resolution in the app);
|
||||||
|
/// empty when there is no list, so the caller falls back to the settings
|
||||||
|
/// defaults.
|
||||||
|
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|event| event.created_at)
|
||||||
|
.map(|event| grasp_list_servers(&event))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the user's published grasp servers: the `g` tags (in order) of
|
||||||
|
/// their latest kind-10317 grasp list in the local database. Returns an
|
||||||
|
/// empty list when the user has no published list, so the caller can fall
|
||||||
|
/// back to the settings defaults.
|
||||||
|
pub(crate) async fn user_grasp_list_servers(
|
||||||
|
client: Client,
|
||||||
|
user: PublicKey,
|
||||||
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
|
let events: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(filters::grasp_list(user))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Ok(latest_grasp_list_servers(events))
|
||||||
|
}
|
||||||
|
|
||||||
/// Push the repository at `path` to every grasp server: a server that
|
/// Push the repository at `path` to every grasp server: a server that
|
||||||
/// rejects the push is logged, but the push only fails when no server
|
/// rejects the push is logged, but the push only fails when no server
|
||||||
/// accepted it. `push` performs the single-server push (e.g.
|
/// accepted it. `push` performs the single-server push (e.g.
|
||||||
@@ -1628,4 +1692,90 @@ mod tests {
|
|||||||
"https://gitnostr.com/npub1test/my-repo.git"
|
"https://gitnostr.com/npub1test/my-repo.git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp06_prs_url_matches_ngit_format() {
|
||||||
|
assert_eq!(
|
||||||
|
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||||
|
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||||
|
);
|
||||||
|
// `ws://` grasp servers (local dev) keep their plain-HTTP base.
|
||||||
|
assert_eq!(
|
||||||
|
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||||
|
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pr_clone_urls_orders_author_first_and_deduplicates() {
|
||||||
|
let prs = vec![
|
||||||
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||||
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||||
|
];
|
||||||
|
let base = vec![
|
||||||
|
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
|
||||||
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||||
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let urls = pr_clone_urls(prs, base);
|
||||||
|
assert_eq!(
|
||||||
|
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"https://a.example/prs/npub1me/repo.git",
|
||||||
|
"https://a.example/npub1owner/repo.git",
|
||||||
|
"https://b.example/npub1owner/repo.git",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let tags: Vec<Tag> = servers
|
||||||
|
.iter()
|
||||||
|
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
|
||||||
|
.collect();
|
||||||
|
EventBuilder::new(Kind::GitUserGraspList, "")
|
||||||
|
.tags(tags)
|
||||||
|
.custom_created_at(Timestamp::from(created_at))
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp_list_servers_reads_g_tags_in_order() {
|
||||||
|
let event = grasp_list_event(
|
||||||
|
&["wss://first.example", "wss://second.example", "not a url"],
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
let servers = grasp_list_servers(&event);
|
||||||
|
assert_eq!(
|
||||||
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
vec!["wss://first.example", "wss://second.example"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
|
||||||
|
let old = grasp_list_event(&["wss://old.example"], 1000);
|
||||||
|
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
|
||||||
|
|
||||||
|
// The newest list wins, its `g` order preserved.
|
||||||
|
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
|
||||||
|
assert_eq!(
|
||||||
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
vec!["wss://fresh.example", "wss://also.example"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The order of the input events does not matter.
|
||||||
|
let servers = latest_grasp_list_servers(vec![fresh, old]);
|
||||||
|
assert_eq!(
|
||||||
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||||
|
vec!["wss://fresh.example", "wss://also.example"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// No list at all: empty, so the caller falls back to the defaults.
|
||||||
|
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,792 @@
|
|||||||
|
//! Local checkout associations ("remember" tier of the PR suggestions):
|
||||||
|
//! which local folders are checkouts of which announced repositories.
|
||||||
|
//!
|
||||||
|
//! Two sources feed the resolution:
|
||||||
|
//!
|
||||||
|
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
|
||||||
|
//! recorded when the user clones a repository from the app or picks a
|
||||||
|
//! folder in the New PR panel.
|
||||||
|
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
|
||||||
|
//! scanned repository whose `origin` URL matches an announcement `clone`
|
||||||
|
//! URL (scheme-insensitive), or whose root commit equals an announcement
|
||||||
|
//! EUC, is a checkout of that announced repository.
|
||||||
|
//!
|
||||||
|
//! The store also computes per-checkout "ready to contribute" statuses
|
||||||
|
//! (branch, base and commits ahead) for the pull-request list banner.
|
||||||
|
//! Everything is resolved on background threads and swapped in as
|
||||||
|
//! [`Arc`]s; the UI never waits for git.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
|
use nostr::prelude::*;
|
||||||
|
use settings::{CheckoutRecord, SettingsStore};
|
||||||
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
|
use crate::git_store::GitStore;
|
||||||
|
use crate::local_repos::LocalReposStore;
|
||||||
|
use crate::repo_list::RepoListStore;
|
||||||
|
|
||||||
|
/// Delay between a refresh request and the actual re-computation, so bursts
|
||||||
|
/// of notifications (settings edits, rescan ticks) collapse into one pass.
|
||||||
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// How often the statuses of open repository panels are refreshed, so a
|
||||||
|
/// checkout committed to or pulled in external git surfaces in the banner
|
||||||
|
/// without reopening the panel.
|
||||||
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Maximum checkouts considered per repository when computing statuses.
|
||||||
|
const MAX_STATUS_CHECKOUTS: usize = 8;
|
||||||
|
|
||||||
|
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalCheckoutsStore {}
|
||||||
|
|
||||||
|
/// One associated local checkout of a repository, with the git facts needed
|
||||||
|
/// to suggest a pull request.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CheckoutStatus {
|
||||||
|
/// The checkout folder.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The branch checked out (`None`-less: detached checkouts are idle).
|
||||||
|
pub branch: String,
|
||||||
|
/// Commit the branch points at, for tip-based PR dedupe.
|
||||||
|
pub head: String,
|
||||||
|
/// The branch this checkout is compared against (announced HEAD branch,
|
||||||
|
/// else `main`, else the first local branch).
|
||||||
|
pub base: String,
|
||||||
|
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
|
||||||
|
pub ahead: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remembered record, with the address already parsed.
|
||||||
|
struct Remembered {
|
||||||
|
path: PathBuf,
|
||||||
|
addr: RepoAddr,
|
||||||
|
last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global store of local-checkout associations and per-checkout statuses.
|
||||||
|
pub struct CheckoutsStore {
|
||||||
|
/// Checkout paths per announced repository: remembered records
|
||||||
|
/// (freshest first) plus scanned repos matched implicitly, deduplicated
|
||||||
|
/// by path. Missing directories are dropped before publishing.
|
||||||
|
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||||
|
/// Ready-to-contribute statuses of the requested repositories.
|
||||||
|
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||||
|
/// Repositories whose statuses are recomputed whenever the inputs
|
||||||
|
/// change (the repository detail panels currently open).
|
||||||
|
status_requested: HashSet<RepoAddr>,
|
||||||
|
/// Announced head branch last provided per requested repository, so a
|
||||||
|
/// recompute defaults the base the same way.
|
||||||
|
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||||
|
refreshing: bool,
|
||||||
|
refresh_dirty: bool,
|
||||||
|
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||||
|
debouncing: bool,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CheckoutsStore {
|
||||||
|
/// Retrieve the global checkouts store.
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalCheckoutsStore>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalCheckoutsStore(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the store: observe the inputs (settings records, the local
|
||||||
|
/// scan, the announcement list) and resolve the associations right away.
|
||||||
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
|
if !cfg!(target_arch = "wasm32") {
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
let local = LocalReposStore::global(cx);
|
||||||
|
let repos = RepoListStore::global(cx);
|
||||||
|
|
||||||
|
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||||
|
this.refresh(cx);
|
||||||
|
}));
|
||||||
|
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||||
|
this.refresh(cx);
|
||||||
|
}));
|
||||||
|
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||||
|
this.refresh(cx);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut store = Self {
|
||||||
|
by_repo: Arc::new(HashMap::new()),
|
||||||
|
statuses: Arc::new(HashMap::new()),
|
||||||
|
status_requested: HashSet::new(),
|
||||||
|
requested_head: HashMap::new(),
|
||||||
|
refreshing: false,
|
||||||
|
refresh_dirty: false,
|
||||||
|
debouncing: false,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cfg!(target_arch = "wasm32") {
|
||||||
|
store.refresh(cx);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember a successful local-checkout use: (re)insert the record with
|
||||||
|
/// a fresh timestamp, so freshest-first ordering follows actual use.
|
||||||
|
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||||
|
if cfg!(target_arch = "wasm32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last_used = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let addr_str = addr.to_string();
|
||||||
|
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
settings.update(cx, |settings, cx| {
|
||||||
|
settings.edit(
|
||||||
|
|s| {
|
||||||
|
s.checkouts
|
||||||
|
.records
|
||||||
|
.retain(|r| !(r.path == path && r.addr == addr_str));
|
||||||
|
s.checkouts.records.push(CheckoutRecord {
|
||||||
|
path,
|
||||||
|
addr: addr_str,
|
||||||
|
last_used,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The associated checkouts of `addr`, freshest first. Empty when none
|
||||||
|
/// are known (or the resolution has not run yet).
|
||||||
|
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||||
|
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the "ready to contribute" statuses of `addr` to be kept
|
||||||
|
/// current (called while the repository's detail panel is open).
|
||||||
|
/// `announced_head` is the announced HEAD branch of the repository
|
||||||
|
/// (from its state announcement), used to default the base.
|
||||||
|
pub fn request_statuses(
|
||||||
|
&mut self,
|
||||||
|
addr: &RepoAddr,
|
||||||
|
announced_head: Option<String>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.status_requested.insert(addr.clone());
|
||||||
|
if announced_head != self.requested_head.get(addr).cloned().flatten() {
|
||||||
|
self.requested_head.insert(addr.clone(), announced_head);
|
||||||
|
}
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ready-to-contribute statuses of `addr`; empty while none are
|
||||||
|
/// known or nothing is ahead.
|
||||||
|
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||||
|
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-resolve associations (and the requested statuses). Debounced:
|
||||||
|
/// bursts of notifications collapse into one pass; requests arriving
|
||||||
|
/// while a pass runs are folded into a follow-up.
|
||||||
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.refreshing {
|
||||||
|
self.refresh_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.debouncing {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.debouncing = true;
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.debouncing = false;
|
||||||
|
this.run_refresh(cx);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolve + apply cycle (debounced entry point).
|
||||||
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.refreshing = true;
|
||||||
|
|
||||||
|
// Inputs snapshot, all cheap shared reads.
|
||||||
|
let records = {
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
settings.read(cx).settings().checkouts.records.clone()
|
||||||
|
};
|
||||||
|
let remembered: Vec<Remembered> = records
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|record| {
|
||||||
|
let addr = record.addr.parse::<RepoAddr>().ok()?;
|
||||||
|
Some(Remembered {
|
||||||
|
path: record.path,
|
||||||
|
addr,
|
||||||
|
last_used: record.last_used,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
|
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||||
|
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
||||||
|
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||||
|
.status_requested
|
||||||
|
.iter()
|
||||||
|
.map(|addr| {
|
||||||
|
(
|
||||||
|
addr.clone(),
|
||||||
|
self.requested_head.get(addr).cloned().flatten(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
// Read the git facts of every scanned repository off the main
|
||||||
|
// thread: origin URL and root commit (both CLI reads).
|
||||||
|
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||||
|
for path in scanned.iter() {
|
||||||
|
// The browser's mirror clones share the announce URLs and
|
||||||
|
// EUCs; they are not user checkouts.
|
||||||
|
if cache_root
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|root| path.starts_with(root))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let origin = signed_git::origin_url(path).ok().flatten();
|
||||||
|
let root = signed_git::root_commit(path).ok().flatten();
|
||||||
|
facts.push((path.clone(), origin, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||||
|
// Missing directories are stale records; drop them.
|
||||||
|
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||||
|
.into_iter()
|
||||||
|
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||||
|
for (addr, announced_head) in &requested {
|
||||||
|
let Some(paths) = associations.get(addr) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let list: Vec<CheckoutStatus> = paths
|
||||||
|
.iter()
|
||||||
|
.take(MAX_STATUS_CHECKOUTS)
|
||||||
|
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
|
||||||
|
.collect();
|
||||||
|
if !list.is_empty() {
|
||||||
|
statuses.insert(addr.clone(), list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, Error>((associations, statuses))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let (associations, statuses) = match work.await {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(_) => {
|
||||||
|
// Git reads are best-effort; keep the last results.
|
||||||
|
return this.update(cx, |this, _cx| {
|
||||||
|
this.refreshing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let again = this.update(cx, |this, cx| {
|
||||||
|
this.by_repo = Arc::new(associations);
|
||||||
|
this.statuses = Arc::new(statuses);
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
this.refreshing = false;
|
||||||
|
if this.refresh_dirty {
|
||||||
|
this.refresh_dirty = false;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if again {
|
||||||
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While any repository panel is open, keep its statuses
|
||||||
|
// current: local commits, pulls and branch switches happen
|
||||||
|
// outside the app and are not otherwise observable.
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if !this.status_requested.is_empty() && !this.debouncing && !this.refreshing {
|
||||||
|
this.debouncing = true;
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(STATUS_POLL).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.debouncing = false;
|
||||||
|
this.run_refresh(cx);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
this.tasks.push(task);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity of a repository URL: host, explicit port and path with a
|
||||||
|
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so
|
||||||
|
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same
|
||||||
|
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style
|
||||||
|
/// or plain paths), which then compare by raw string.
|
||||||
|
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||||
|
let mut path = parsed.path().trim_matches('/').to_owned();
|
||||||
|
if let Some(stripped) = path.strip_suffix(".git") {
|
||||||
|
path = stripped.to_owned();
|
||||||
|
}
|
||||||
|
Some((host, parsed.port(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two repository URLs point at the same repository, ignoring the
|
||||||
|
/// transport scheme (see [`url_identity`]).
|
||||||
|
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||||
|
match (url_identity(a), url_identity(b)) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => a == b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the associations between local checkouts and announced
|
||||||
|
/// repositories: remembered records (freshest first per repository),
|
||||||
|
/// followed by scanned repositories matched by origin URL or EUC.
|
||||||
|
/// Deduplicated by path, keeping the first (remembered) occurrence.
|
||||||
|
fn resolve_associations<'a>(
|
||||||
|
remembered: &[Remembered],
|
||||||
|
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||||
|
announcements: impl IntoIterator<Item = &'a Announcement>,
|
||||||
|
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
||||||
|
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||||
|
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
||||||
|
|
||||||
|
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
||||||
|
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
||||||
|
for record in sorted {
|
||||||
|
let paths = out.entry(record.addr.clone()).or_default();
|
||||||
|
if !paths.contains(&record.path) {
|
||||||
|
paths.push(record.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, origin, root) in scanned {
|
||||||
|
for announcement in &announcements {
|
||||||
|
let url_match = origin.as_deref().is_some_and(|origin| {
|
||||||
|
announcement
|
||||||
|
.clone
|
||||||
|
.iter()
|
||||||
|
.any(|url| same_repo_url(origin, url.as_str()))
|
||||||
|
});
|
||||||
|
let euc_match = root
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||||
|
if url_match || euc_match {
|
||||||
|
let paths = out.entry(announcement.addr()).or_default();
|
||||||
|
if !paths.contains(path) {
|
||||||
|
paths.push(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the worktree of `path` has uncommitted changes (a dirty
|
||||||
|
/// checkout is never suggested: the proposal should cover committed work).
|
||||||
|
fn worktree_dirty(path: &Path) -> bool {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits in `base..branch` of the checkout at `path` (`git rev-list
|
||||||
|
/// --count`); `0` when the range is empty or cannot be computed.
|
||||||
|
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(["rev-list", "--count", &format!("{base}..{branch}")])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(output) => String::from_utf8_lossy(&output.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0),
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The branch checked out at `path` (`git branch --show-current`), `None`
|
||||||
|
/// when detached.
|
||||||
|
fn current_branch_of(path: &Path) -> Option<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(["branch", "--show-current"])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||||
|
(!branch.is_empty()).then_some(branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ready-to-contribute status of one checkout, or `None` when it is
|
||||||
|
/// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of
|
||||||
|
/// its base. The base defaults like the New PR panel: the announced HEAD
|
||||||
|
/// branch when the checkout has it, else `main`, else the first branch.
|
||||||
|
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||||
|
let branches = signed_git::worktree_branches(path).ok()?;
|
||||||
|
if branches.is_empty() || worktree_dirty(path) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let branch = current_branch_of(path)?;
|
||||||
|
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||||
|
let base = announced_head
|
||||||
|
.filter(|name| branches.iter().any(|b| b == name))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
||||||
|
.or_else(|| branches.first().cloned())?;
|
||||||
|
if base == branch {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ahead = commits_ahead(path, &base, &branch);
|
||||||
|
(ahead > 0).then_some(CheckoutStatus {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
branch,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
ahead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
|
||||||
|
/// caller) already proposes the same change as `checkout`: authored by
|
||||||
|
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
|
||||||
|
/// `c` tip tag matching the checkout's HEAD commit.
|
||||||
|
pub fn pr_proposes_checkout(
|
||||||
|
pr: &Event,
|
||||||
|
open: bool,
|
||||||
|
user: PublicKey,
|
||||||
|
checkout: &CheckoutStatus,
|
||||||
|
) -> bool {
|
||||||
|
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let branch_matches = pr
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.kind() == "branch-name")
|
||||||
|
.and_then(|t| t.content())
|
||||||
|
.is_some_and(|name| name == checkout.branch);
|
||||||
|
// A renamed branch falls back to the proposed tip commit.
|
||||||
|
let tip_matches = pr
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.kind() == "c")
|
||||||
|
.and_then(|t| t.content())
|
||||||
|
.is_some_and(|tip| tip == checkout.head);
|
||||||
|
branch_matches || tip_matches
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use signed_core::{RepoAddr, repo_addr};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_repo_url_ignores_the_transport_scheme() {
|
||||||
|
// grasp announce vs https origin, with and without `.git`.
|
||||||
|
assert!(same_repo_url(
|
||||||
|
"grasp://relay.ngit.dev/npub1test/repo",
|
||||||
|
"https://relay.ngit.dev/npub1test/repo.git"
|
||||||
|
));
|
||||||
|
assert!(same_repo_url(
|
||||||
|
"ws://localhost:8080/npub1test/repo",
|
||||||
|
"http://localhost:8080/npub1test/repo"
|
||||||
|
));
|
||||||
|
// The port and the path matter.
|
||||||
|
assert!(!same_repo_url(
|
||||||
|
"wss://localhost:8081/npub1test/repo",
|
||||||
|
"wss://localhost:8080/npub1test/repo"
|
||||||
|
));
|
||||||
|
assert!(!same_repo_url(
|
||||||
|
"wss://host/npub1test/repo",
|
||||||
|
"wss://host/npub1other/repo"
|
||||||
|
));
|
||||||
|
// Unparseable URLs compare literally.
|
||||||
|
assert!(same_repo_url("/local/path", "/local/path"));
|
||||||
|
assert!(!same_repo_url("/local/path", "/local/other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
|
||||||
|
Remembered {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
addr: addr(id),
|
||||||
|
last_used,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanned(
|
||||||
|
path: &str,
|
||||||
|
origin: Option<&str>,
|
||||||
|
root: Option<&str>,
|
||||||
|
) -> (PathBuf, Option<String>, Option<String>) {
|
||||||
|
(
|
||||||
|
PathBuf::from(path),
|
||||||
|
origin.map(str::to_owned),
|
||||||
|
root.map(str::to_owned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||||
|
|
||||||
|
fn owner() -> PublicKey {
|
||||||
|
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addr(id: &str) -> RepoAddr {
|
||||||
|
repo_addr(owner(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one announcement by the fixed test owner with `clone` URLs and
|
||||||
|
/// an EUC.
|
||||||
|
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
|
||||||
|
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
|
||||||
|
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
|
||||||
|
for url in clones {
|
||||||
|
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
|
||||||
|
}
|
||||||
|
if let Some(euc) = euc {
|
||||||
|
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
|
||||||
|
}
|
||||||
|
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed");
|
||||||
|
Announcement::from_event(&event).expect("parsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_orders_remembered_freshest_first() {
|
||||||
|
let announcements = vec![announcement("repo", &[], None)];
|
||||||
|
let base = addr("repo");
|
||||||
|
|
||||||
|
let resolved = resolve_associations(
|
||||||
|
&[
|
||||||
|
remembered("/old", "repo", 100),
|
||||||
|
remembered("/fresh", "repo", 200),
|
||||||
|
remembered("/other", "unrelated", 300),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
&announcements,
|
||||||
|
);
|
||||||
|
|
||||||
|
let paths = resolved.get(&base).expect("associations");
|
||||||
|
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
|
||||||
|
// Records for repositories without announcements stay inert.
|
||||||
|
assert_eq!(resolved.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_matches_scanned_repos_by_origin_and_euc() {
|
||||||
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
let announcements = vec![
|
||||||
|
announcement("repo", &["grasp://host/npub1x/repo"], None),
|
||||||
|
announcement("family", &[], Some(euc)),
|
||||||
|
];
|
||||||
|
let repo = addr("repo");
|
||||||
|
let family = addr("family");
|
||||||
|
|
||||||
|
let resolved = resolve_associations(
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
// Origin matches modulo scheme and the `.git` suffix.
|
||||||
|
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
|
||||||
|
// Root commit matches the family EUC.
|
||||||
|
scanned("/family-checkout", None, Some(euc)),
|
||||||
|
// Neither matches anything.
|
||||||
|
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
|
||||||
|
],
|
||||||
|
&announcements,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.get(&repo).expect("repo matches"),
|
||||||
|
&vec![PathBuf::from("/clone")]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.get(&family).expect("family matches"),
|
||||||
|
&vec![PathBuf::from("/family-checkout")]
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_deduplicates_paths_remembering_first() {
|
||||||
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
let announcements = vec![announcement(
|
||||||
|
"repo",
|
||||||
|
&["https://host/npub1x/repo.git"],
|
||||||
|
Some(euc),
|
||||||
|
)];
|
||||||
|
let base = addr("repo");
|
||||||
|
|
||||||
|
// The same path is both remembered and scanned (its origin matches);
|
||||||
|
// the remembered occurrence wins and it is listed once.
|
||||||
|
let resolved = resolve_associations(
|
||||||
|
&[remembered("/shared", "repo", 100)],
|
||||||
|
&[
|
||||||
|
scanned("/shared", Some("https://host/npub1x/repo"), None),
|
||||||
|
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
|
||||||
|
],
|
||||||
|
&announcements,
|
||||||
|
);
|
||||||
|
|
||||||
|
let paths = resolved.get(&base).expect("associations");
|
||||||
|
assert_eq!(
|
||||||
|
paths,
|
||||||
|
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_status_reports_ahead_branches_only() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("repo");
|
||||||
|
let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init");
|
||||||
|
let run = |args: &[&str]| {
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(&path)
|
||||||
|
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.expect("git");
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
};
|
||||||
|
let commit = |message: &str| {
|
||||||
|
run(&["add", "-A"]);
|
||||||
|
run(&["commit", "-m", message]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A feature branch ahead of main: ready to contribute.
|
||||||
|
run(&["checkout", "-b", "feature"]);
|
||||||
|
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||||
|
commit("feature work");
|
||||||
|
let status = checkout_status(&path, Some("main")).expect("status");
|
||||||
|
assert_eq!(status.branch, "feature");
|
||||||
|
assert_eq!(status.base, "main");
|
||||||
|
assert_eq!(status.ahead, 1);
|
||||||
|
assert_eq!(status.head.len(), 40);
|
||||||
|
|
||||||
|
// Dirty worktrees are never suggested.
|
||||||
|
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
|
||||||
|
assert!(checkout_status(&path, Some("main")).is_none());
|
||||||
|
run(&["checkout", "--", "."]);
|
||||||
|
|
||||||
|
// Even with main: nothing to propose.
|
||||||
|
run(&["checkout", "main"]);
|
||||||
|
assert_eq!(checkout_status(&path, Some("main")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||||
|
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
|
||||||
|
let tags: Vec<Tag> = tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||||
|
.collect();
|
||||||
|
EventBuilder::new(Kind::GitPullRequest, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(branch: &str, head: &str) -> CheckoutStatus {
|
||||||
|
CheckoutStatus {
|
||||||
|
path: PathBuf::from("/checkout"),
|
||||||
|
branch: branch.to_owned(),
|
||||||
|
head: head.to_owned(),
|
||||||
|
base: "main".to_owned(),
|
||||||
|
ahead: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pr_proposes_checkout_matches_branch_or_tip() {
|
||||||
|
let author = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||||
|
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
|
||||||
|
// A matching `branch-name` covers the proposal.
|
||||||
|
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
|
||||||
|
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
|
||||||
|
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||||
|
|
||||||
|
// Without the branch name (renamed), the `c` tip still matches.
|
||||||
|
let pr = pr_event(
|
||||||
|
author,
|
||||||
|
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
|
||||||
|
);
|
||||||
|
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||||
|
|
||||||
|
// Someone else's PR, a closed PR, a different branch and a missing
|
||||||
|
// tip all leave the checkout uncovered.
|
||||||
|
let pr = pr_event(author, &[&["branch-name", "feature"]]);
|
||||||
|
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
|
||||||
|
let other = pr_event(
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||||
|
&[&["branch-name", "feature"]],
|
||||||
|
);
|
||||||
|
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
|
||||||
|
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
|
||||||
|
assert!(!pr_proposes_checkout(
|
||||||
|
&other_branch,
|
||||||
|
true,
|
||||||
|
other_branch.pubkey,
|
||||||
|
&status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod local_repos;
|
mod local_repos;
|
||||||
mod profile;
|
mod profile;
|
||||||
@@ -8,6 +9,7 @@ mod repo_list;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use backend::{Backend, BackendEvent};
|
pub use backend::{Backend, BackendEvent};
|
||||||
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use git_store::GitStore;
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext, Entity};
|
||||||
pub use local_repos::LocalReposStore;
|
pub use local_repos::LocalReposStore;
|
||||||
@@ -22,7 +24,7 @@ pub use utils::shorten_pubkey;
|
|||||||
/// Call once at startup, before opening any window that uses the stores.
|
/// Call once at startup, before opening any window that uses the stores.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
||||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.ok();
|
.ok();
|
||||||
@@ -38,15 +40,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
|||||||
Backend::set_global(entity.clone(), cx);
|
Backend::set_global(entity.clone(), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
// Seed the explore list from the local database; relay syncs continue
|
|
||||||
// in the background.
|
|
||||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||||
|
|
||||||
// The clone cache is native-only; wasm registers an empty store so
|
|
||||||
// `GitStore::global` still works.
|
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
|
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
|
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
@@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
|
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||||
Backend::set_global(entity.clone(), cx);
|
Backend::set_global(entity.clone(), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||||
|
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-33
@@ -14,7 +14,9 @@ use signed_core::{
|
|||||||
subject_override,
|
subject_override,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, grasp_base_url};
|
use crate::backend::{
|
||||||
|
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
||||||
|
};
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||||
@@ -645,11 +647,15 @@ impl RepoStore {
|
|||||||
/// parsed from the series' last `From <commit>` header (the tip); without
|
/// parsed from the series' last `From <commit>` header (the tip); without
|
||||||
/// one publishing is refused, because the PR's `c` tag must carry a real
|
/// one publishing is refused, because the PR's `c` tag must carry a real
|
||||||
/// commit id for other NIP-34 clients to verify and apply the proposal.
|
/// commit id for other NIP-34 clients to verify and apply the proposal.
|
||||||
/// The `clone` tag carries the announced mirror URLs, and when
|
///
|
||||||
/// `push_from` is set the tip is pushed to those servers under
|
/// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first
|
||||||
/// `refs/nostr/<event-id>` (best-effort) before the PR is published, so
|
/// (resolved from their kind-10317 grasp list, falling back to the
|
||||||
/// the commit is actually downloadable there; the linked patch stays the
|
/// settings defaults) plus the announced mirror URLs, so the tip is
|
||||||
/// source of truth either way.
|
/// downloadable on the author's own hosting even when the base project
|
||||||
|
/// accepts nothing. When `push_from` is set, the tip is pushed to those
|
||||||
|
/// servers under `refs/nostr/<event-id>` (best-effort, author servers
|
||||||
|
/// first) before the PR is published; the linked patch stays the source
|
||||||
|
/// of truth either way.
|
||||||
///
|
///
|
||||||
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
|
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
|
||||||
/// publishes a kind-1633 status right after the PR event. `merge_base`
|
/// publishes a kind-1633 status right after the PR event. `merge_base`
|
||||||
@@ -704,28 +710,44 @@ impl RepoStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let signer = backend.read(cx).signer();
|
let signer = backend.read(cx).signer();
|
||||||
|
|
||||||
if backend.read(cx).current_user().is_none() {
|
let Some(user) = backend.read(cx).current_user() else {
|
||||||
self.last_error = Some("Sign in to open a pull request".into());
|
self.last_error = Some("Sign in to open a pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
// The author's npub names their GRASP-06 namespace (`/prs/...`).
|
||||||
|
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
|
||||||
|
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
let owner = self.addr.public_key;
|
let owner = self.addr.public_key;
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
|
let repo_id = addr.identifier.clone();
|
||||||
let (push_owner, push_repo_id, push_relays) = self
|
let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex());
|
||||||
|
let push_relays = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| {
|
.map(|a| a.relays.clone())
|
||||||
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
|
|
||||||
(owner, a.id.clone(), a.relays.clone())
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// GRASP-06 hosting falls back to the settings defaults when
|
||||||
|
// the author has no published grasp list
|
||||||
|
let defaults: Vec<RelayUrl> = {
|
||||||
|
let settings = settings::SettingsStore::global(cx).read(cx).settings();
|
||||||
|
let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() {
|
||||||
|
settings::DEFAULT_GRASP_SERVERS
|
||||||
|
.iter()
|
||||||
|
.map(|url| (*url).to_owned())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
settings.grasp_servers.default_servers.clone()
|
||||||
|
};
|
||||||
|
urls.iter()
|
||||||
|
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
// The PR references the root patch event so viewers can find
|
// The PR references the root patch event so viewers can find the patch without carrying it inline.
|
||||||
// the patch without carrying it inline.
|
|
||||||
let root_patch = match publish_patch_series(
|
let root_patch = match publish_patch_series(
|
||||||
&this,
|
&this,
|
||||||
cx,
|
cx,
|
||||||
@@ -747,20 +769,76 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GRASP-06: the tip is pushed to the author's own grasp servers
|
||||||
|
// under `/prs/<author-npub>/<repo-id>.git`, so contributing to
|
||||||
|
// someone else's project never depends on their servers
|
||||||
|
// accepting the push. Resolve them from the author's latest
|
||||||
|
// kind-10317 grasp list; the settings defaults stand in when no
|
||||||
|
// list is published (or the query fails).
|
||||||
|
let author_servers = {
|
||||||
|
let query = this.update(cx, |_this, cx| {
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
|
user_grasp_list_servers(client, user)
|
||||||
|
})?;
|
||||||
|
match cx.background_spawn(query).await {
|
||||||
|
Ok(published) if !published.is_empty() => published,
|
||||||
|
_ => defaults,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let author_targets: Vec<(String, String)> = {
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
for server in &author_servers {
|
||||||
|
let Some(base) = grasp_base_url(server) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let url = grasp06_prs_url(&base, &author_npub, &repo_id);
|
||||||
|
if !targets.iter().any(|(existing, _)| existing == &url) {
|
||||||
|
targets.push((url, server.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets
|
||||||
|
};
|
||||||
|
let base_targets: Vec<(String, String)> = {
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
for relay in &push_relays {
|
||||||
|
let Some(base) = grasp_base_url(relay) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let url = format!("{base}/{base_npub}/{repo_id}.git");
|
||||||
|
if !targets.iter().any(|(existing, _)| existing == &url) {
|
||||||
|
targets.push((url, relay.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets
|
||||||
|
};
|
||||||
|
|
||||||
let builder = this.update(cx, |this, _cx| {
|
let builder = this.update(cx, |this, _cx| {
|
||||||
|
// NIP-34: PRs carry at least one clone URL where the tip
|
||||||
|
// commit can be downloaded. The author's `/prs/` URLs come
|
||||||
|
// first (author-controlled, most likely alive), then the
|
||||||
|
// announced mirrors. The list is fixed before signing: the
|
||||||
|
// pushed ref name embeds the event id, so every candidate
|
||||||
|
// URL is listed up front; dead URLs are inert, the linked
|
||||||
|
// patch stays the source of truth.
|
||||||
|
let prs_urls: Vec<Url> = author_targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(url, _)| Url::parse(url).ok())
|
||||||
|
.collect();
|
||||||
|
let base_clone = this
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.clone.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||||
|
|
||||||
let builder = GitPullRequest {
|
let builder = GitPullRequest {
|
||||||
repository: this.addr.clone(),
|
repository: this.addr.clone(),
|
||||||
content: description,
|
content: description,
|
||||||
subject,
|
subject,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
branch_name,
|
branch_name,
|
||||||
// NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded,
|
clone,
|
||||||
// the announced mirrors are also the servers the tip is pushed to below.
|
|
||||||
clone: this
|
|
||||||
.announcement
|
|
||||||
.as_ref()
|
|
||||||
.map(|a| a.clone.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
current_commit,
|
current_commit,
|
||||||
root_patch_event: Some(root_patch.id),
|
root_patch_event: Some(root_patch.id),
|
||||||
merge_base: merge_base
|
merge_base: merge_base
|
||||||
@@ -793,20 +871,21 @@ impl RepoStore {
|
|||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
let tip = tip.clone();
|
let tip = tip.clone();
|
||||||
let reference = reference.clone();
|
let reference = reference.clone();
|
||||||
let owner = push_owner.clone();
|
// Author servers first, then the base repository's
|
||||||
let repo_id = push_repo_id.clone();
|
// announced grasp servers (best-effort redundancy).
|
||||||
let relays = push_relays.clone();
|
let targets: Vec<(String, String)> = author_targets
|
||||||
|
.into_iter()
|
||||||
|
.chain(base_targets)
|
||||||
|
.collect();
|
||||||
async move {
|
async move {
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
let mut pushed = 0;
|
let mut pushed = 0;
|
||||||
for relay in &relays {
|
for (url, label) in &targets {
|
||||||
let Some(base) = grasp_base_url(relay) else {
|
match signed_git::push_commit_ref(
|
||||||
continue;
|
&path, url, &tip, &reference,
|
||||||
};
|
) {
|
||||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
|
||||||
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
|
|
||||||
Ok(()) => pushed += 1,
|
Ok(()) => pushed += 1,
|
||||||
Err(e) => failures.push(format!("{relay}: {e}")),
|
Err(e) => failures.push(format!("{label}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(pushed, failures)
|
(pushed, failures)
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ impl IssuesView {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
repo_name: SharedString,
|
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let repo_name = store.read(cx).name();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ use gpui_component::{
|
|||||||
VirtualListScrollHandle, h_flex, v_flex,
|
VirtualListScrollHandle, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||||
use signed_core::{Announcement, RepoAddr, filters};
|
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
||||||
use signed_git::{CommitList, FileCommit};
|
use signed_git::{CommitList, FileCommit};
|
||||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore};
|
use signed_state::{
|
||||||
|
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
|
||||||
|
RepoListStore, RepoStore, pr_proposes_checkout,
|
||||||
|
};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||||
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||||
|
|
||||||
@@ -198,6 +201,17 @@ pub struct RepoDetailView {
|
|||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Subscriptions keeping the selectors' confirm events alive.
|
/// Subscriptions keeping the selectors' confirm events alive.
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
|
/// Observes the checkouts store, whose statuses feed the "ready to
|
||||||
|
/// contribute" banner of the repository panel.
|
||||||
|
_checkouts_subscription: Subscription,
|
||||||
|
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
||||||
|
banner_dismissed: HashSet<(PathBuf, String)>,
|
||||||
|
/// The announced HEAD the ready-statuses were last requested with, and
|
||||||
|
/// whether they were requested at all (re-requested only when the HEAD
|
||||||
|
/// — the base default — changes, e.g. when the store's first refresh
|
||||||
|
/// lands).
|
||||||
|
ready_requested: bool,
|
||||||
|
ready_head: Option<String>,
|
||||||
/// Upstream repository (from this fork's `u` tag) the user asked to
|
/// Upstream repository (from this fork's `u` tag) the user asked to
|
||||||
/// open, while its announcement is still being fetched.
|
/// open, while its announcement is still being fetched.
|
||||||
pending_upstream: Option<RepoAddr>,
|
pending_upstream: Option<RepoAddr>,
|
||||||
@@ -219,7 +233,16 @@ impl RepoDetailView {
|
|||||||
let relays = initial.relays.clone();
|
let relays = initial.relays.clone();
|
||||||
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
|
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
|
||||||
|
|
||||||
Self::new_common(dock_area, Some(initial), Some(store), None, window, cx)
|
let mut view = Self::new_common(
|
||||||
|
dock_area,
|
||||||
|
Some(initial),
|
||||||
|
Some(store.clone()),
|
||||||
|
None,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
view.attach_store(&store, cx);
|
||||||
|
view
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a local repository discovered by the scan. There is no
|
/// Open a local repository discovered by the scan. There is no
|
||||||
@@ -328,6 +351,11 @@ impl RepoDetailView {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
|
_checkouts_subscription: cx
|
||||||
|
.observe(&CheckoutsStore::global(cx), |_this, _store, cx| cx.notify()),
|
||||||
|
banner_dismissed: HashSet::new(),
|
||||||
|
ready_requested: false,
|
||||||
|
ready_head: None,
|
||||||
pending_upstream: None,
|
pending_upstream: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,7 +576,7 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (clone_urls, name) = {
|
let (clone_urls, name, addr) = {
|
||||||
let Some(announcement) = self.announcement(cx) else {
|
let Some(announcement) = self.announcement(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -569,7 +597,7 @@ impl RepoDetailView {
|
|||||||
} else {
|
} else {
|
||||||
name
|
name
|
||||||
};
|
};
|
||||||
(clone_urls, name)
|
(clone_urls, name, addr)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.cloning = true;
|
self.cloning = true;
|
||||||
@@ -599,14 +627,23 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
let destination = folder.join(&name);
|
let destination = folder.join(&name);
|
||||||
let destination_for_open = destination.clone();
|
let destination_for_open = destination.clone();
|
||||||
|
let clone_target = destination_for_open.clone();
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) })
|
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &clone_target) })
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
this.cloning = false;
|
this.cloning = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => cx.open_with_system(&destination_for_open),
|
Ok(_) => {
|
||||||
|
cx.open_with_system(&destination_for_open);
|
||||||
|
// Remember the clone as a checkout of this
|
||||||
|
// repository, so the New PR panel pre-fills it.
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
checkouts.update(cx, |store, cx| {
|
||||||
|
store.record(destination, addr, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
this.error = Some(format!("Failed to clone: {error}").into());
|
this.error = Some(format!("Failed to clone: {error}").into());
|
||||||
}
|
}
|
||||||
@@ -934,15 +971,7 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
|
||||||
IssuesView::new(
|
|
||||||
self.dock_area.clone(),
|
|
||||||
store,
|
|
||||||
self.display_name(cx),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, cx| {
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||||
@@ -958,15 +987,7 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
|
||||||
PullRequestsView::new(
|
|
||||||
self.dock_area.clone(),
|
|
||||||
store,
|
|
||||||
self.display_name(cx),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, cx| {
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||||
@@ -1798,14 +1819,127 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
let store =
|
let store =
|
||||||
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
||||||
// Re-render when the store refreshes (issues, PRs, statuses).
|
// Re-render on store refreshes (issues, PRs, statuses) and keep the
|
||||||
self._subscriptions
|
// "ready to contribute" statuses of this repository requested.
|
||||||
.push(cx.observe(&store, |_this, _store, cx| cx.notify()));
|
self.attach_store(&store, cx);
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self.initial = Some(announcement);
|
self.initial = Some(announcement);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observe the repository's store (re-render on refreshes) and request
|
||||||
|
/// the "ready to contribute" statuses for it.
|
||||||
|
fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
|
||||||
|
self._subscriptions
|
||||||
|
.push(cx.observe(store, |this, _store, cx| {
|
||||||
|
cx.notify();
|
||||||
|
// The first refresh fills the announced HEAD, which defaults
|
||||||
|
// the banner's base branch; re-request when it changes.
|
||||||
|
this.refresh_ready_statuses(cx);
|
||||||
|
}));
|
||||||
|
self.refresh_ready_statuses(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)request the ready statuses of this repository when the announced
|
||||||
|
/// HEAD — the base the checkouts are compared against — changed since
|
||||||
|
/// the last request.
|
||||||
|
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(store) = self.store.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let head = store.read(cx).head.clone();
|
||||||
|
if self.ready_requested && self.ready_head == head {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ready_requested = true;
|
||||||
|
self.ready_head = head.clone();
|
||||||
|
let addr = store.read(cx).addr().clone();
|
||||||
|
CheckoutsStore::global(cx).update(cx, |store, cx| {
|
||||||
|
store.request_statuses(&addr, head, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first checkout ready for a pull request on this repository,
|
||||||
|
/// not covered by an open PR of the signed-in user and not dismissed in this panel.
|
||||||
|
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
|
||||||
|
let store = self.store.as_ref()?;
|
||||||
|
let addr = store.read(cx).addr().clone();
|
||||||
|
|
||||||
|
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
|
||||||
|
let user = Backend::global(cx).read(cx).current_user()?;
|
||||||
|
|
||||||
|
'status: for status in statuses {
|
||||||
|
if self
|
||||||
|
.banner_dismissed
|
||||||
|
.contains(&(status.path.clone(), status.branch.clone()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let store = store.read(cx);
|
||||||
|
for pr in &store.pull_requests {
|
||||||
|
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
|
||||||
|
{
|
||||||
|
continue 'status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Some(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "ready to contribute" banner of the repository panel: message,
|
||||||
|
/// a Create action opening the prefilled New PR panel, and a dismiss
|
||||||
|
/// control.
|
||||||
|
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||||
|
let status = self.ready_suggestion(cx)?;
|
||||||
|
let commits = if status.ahead == 1 {
|
||||||
|
"1 commit".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{} commits", status.ahead)
|
||||||
|
};
|
||||||
|
let message = SharedString::from(format!(
|
||||||
|
"{} is {} ahead of {} in {}",
|
||||||
|
status.branch,
|
||||||
|
commits,
|
||||||
|
status.base,
|
||||||
|
status.path.display()
|
||||||
|
));
|
||||||
|
let key = (status.path.clone(), status.branch.clone());
|
||||||
|
let view = cx.entity().clone();
|
||||||
|
|
||||||
|
Some(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.px_4()
|
||||||
|
.pt_1()
|
||||||
|
.w_full()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
Alert::info("repo-ready-to-contribute", message)
|
||||||
|
.banner()
|
||||||
|
.flex_1()
|
||||||
|
.on_close(move |_event, _window, cx| {
|
||||||
|
view.update(cx, |this, _| {
|
||||||
|
this.banner_dismissed.insert(key.clone());
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("create-pr-from-banner")
|
||||||
|
.small()
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.label("Create pull request")
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
if let Some(store) = this.store.clone() {
|
||||||
|
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The tab row shared by both header variants: Files/Commits tabs, the
|
/// The tab row shared by both header variants: Files/Commits tabs, the
|
||||||
/// HEAD commit button and the branch/tag selectors.
|
/// HEAD commit button and the branch/tag selectors.
|
||||||
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
@@ -2036,6 +2170,9 @@ impl Render for RepoDetailView {
|
|||||||
.id("repo")
|
.id("repo")
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(self.render_header(cx))
|
.child(self.render_header(cx))
|
||||||
|
.when_some(self.render_ready_banner(cx), |this, banner| {
|
||||||
|
this.child(banner)
|
||||||
|
})
|
||||||
.when_some(self.error.clone(), |this, error| {
|
.when_some(self.error.clone(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
Alert::error("repo-error", error)
|
Alert::error("repo-error", error)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -92,10 +92,11 @@ impl PullRequestsView {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
repo_name: SharedString,
|
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let repo_name = store.read(cx).name();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
|
|||||||
+584
@@ -0,0 +1,584 @@
|
|||||||
|
# PLAN — PR contribution flows: fork compare + GRASP-06 hosting + checkout suggestions
|
||||||
|
|
||||||
|
> **Status (2026-09-03): implemented.** Steps 1-9 and 11 are done on
|
||||||
|
> `feat/fork`; step 10 (sidebar "Ready to contribute" group) remains
|
||||||
|
> deferred as planned (v2, optional). See `docs/PR_FLOW.md` for the
|
||||||
|
> resulting flow; the per-step sections below record what shipped and
|
||||||
|
> where the plan was refined during implementation.
|
||||||
|
|
||||||
|
Combined implementation plan for three coordinated improvements to Signed's pull
|
||||||
|
request experience:
|
||||||
|
|
||||||
|
- **A. Fork-aware compare** — the New PR panel's compare side can come from an
|
||||||
|
announced fork repository's branch (fetched into the base repo's GitCache
|
||||||
|
mirror), instead of only from a user-picked local checkout.
|
||||||
|
- **B. GRASP-06 hosting** — PR tips are pushed to the *author's own* grasp
|
||||||
|
servers under `/prs/<author-npub>/<repo-id>.git` and advertised in the PR's
|
||||||
|
`clone` tag, so contributing to someone else's project never depends on their
|
||||||
|
servers accepting anything from you.
|
||||||
|
- **C. Checkout associations & suggestions** — remember/derive which local
|
||||||
|
folders are checkouts of which announced repos, auto-prefill the New PR panel
|
||||||
|
(no folder picker for the common case), and suggest creating a PR when a
|
||||||
|
branch is ahead with no open PR (GitHub-like nudge, NIP-34-native dedupe).
|
||||||
|
|
||||||
|
Guiding principles (agreed): follow nostr + grasp + ngit, not GitHub;
|
||||||
|
NIP-34/GRASP-06 event surface stays untouched (no new tags/kinds, forks never
|
||||||
|
appear in events); every flow keeps the patch series as the source of truth;
|
||||||
|
no over-engineering — reuse existing stores, patterns and git helpers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Protocol grounding (what we may and may not do)
|
||||||
|
|
||||||
|
### 1.1 NIP-34 facts used by this plan
|
||||||
|
|
||||||
|
- A PR (kind-1618) is addressed to the **base** repo coordinate (`a` tag) and
|
||||||
|
carries `c` (tip), `merge-base` (common ancestor with the target branch),
|
||||||
|
`branch-name`, `clone` (≥1 URL where the tip commit can be downloaded),
|
||||||
|
`e` → root patch event, `r` (EUC), `p` (base owner). Patches are
|
||||||
|
NIP-10-chained kind-1617 events, ≤60 KB each. Statuses 1630–1633 resolve the
|
||||||
|
PR.
|
||||||
|
- Repository announcements (30617): `u` tag marks a subordinate fork
|
||||||
|
(`30617:<pubkey>:<id>` coordinate or git URL); the `r`/`euc` tag identifies
|
||||||
|
the earliest unique commit, shared by every repo of the same project family
|
||||||
|
(forks, mirrors). Both are **read-only inputs** for discovery.
|
||||||
|
- Kind-10317 is the user grasp list (`g` tags, in preference order) — read-only
|
||||||
|
input for hosting.
|
||||||
|
- Anybody may open a PR on any announced repo; only the author may update it
|
||||||
|
(1619); only the author or a maintainer may set status; merge is the
|
||||||
|
maintainer's action.
|
||||||
|
- "Patches and PRs to a repository SHOULD be sent to the relays specified in
|
||||||
|
that repository's announcement" — i.e. the **base** repo's relays, always.
|
||||||
|
|
||||||
|
### 1.2 GRASP-06 facts (as ngit implements it — verified against ngit-cli)
|
||||||
|
|
||||||
|
- GRASP-06 servers expose a contributor namespace:
|
||||||
|
`http(s)://<host>/prs/<contributor-npub>/<repo-id>.git` (input
|
||||||
|
`ws://`/`wss://` base URLs normalize to `http(s)://`; npub in the URL, hex
|
||||||
|
on the server's disk — a server detail). Anyone can push there; no
|
||||||
|
announcement, no maintainer rights, no fork repo required.
|
||||||
|
- The author's server is tried **first**; the base repo's announcement grasps
|
||||||
|
still receive the same `refs/nostr/<event-id>` push as redundancy.
|
||||||
|
- The PR event shape is unchanged; only *which URLs the `clone` tag lists*
|
||||||
|
differs.
|
||||||
|
|
||||||
|
### 1.3 Consequences (locked decisions)
|
||||||
|
|
||||||
|
- Publishing keeps today's event set and tag semantics. The fork changes only
|
||||||
|
where the patch series is generated from; GRASP-06 changes only where the tip
|
||||||
|
is pushed and advertised; suggestions change nothing on the wire.
|
||||||
|
- We never publish a reference to the fork or to `/prs/` hosting beyond legal
|
||||||
|
`clone` URLs.
|
||||||
|
- The PR `clone` tag is fixed before signing (the `refs/nostr/<event-id>` ref
|
||||||
|
name embeds the event id), so it carries the full candidate URL set
|
||||||
|
(author `/prs/` URLs + base announcement clone URLs). Dead URLs are inert —
|
||||||
|
patch events remain the truth — and ngit readers fail over across URLs.
|
||||||
|
(ngit instead rebuilds the event per server to keep a single clone URL; we
|
||||||
|
deliberately do not copy that.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Workstream A — Fork-aware compare
|
||||||
|
|
||||||
|
### 2.1 Model
|
||||||
|
|
||||||
|
The panel keeps today's behavior as the default source and adds a second:
|
||||||
|
|
||||||
|
- **Checkout** (existing): both selectors list a user-picked local checkout's
|
||||||
|
branches; git ops + tip push run in the checkout.
|
||||||
|
- **Fork** (new): the flow runs against the **base repo's GitCache mirror**
|
||||||
|
`P_base = GitStore::global(cx).cache().repo_path(&base_addr)` (ensured via
|
||||||
|
`GitCache::ensure_clone(&base_addr, &base_clone_urls)` + `fetch_all`):
|
||||||
|
- "Merge Into" lists `P_base` branches (`refs/remotes/origin/*`);
|
||||||
|
- "Pull From" lists the chosen fork's branches, imported into `P_base`;
|
||||||
|
- `merge-base`, range commits/diff, `format-patch`, and the tip push all run
|
||||||
|
against `P_base` — both histories share one object store, and commit-diff
|
||||||
|
rows work because fork commits live there.
|
||||||
|
|
||||||
|
### 2.2 Git mechanics (import namespace)
|
||||||
|
|
||||||
|
Fork heads are fetched into `P_base` under a private namespace:
|
||||||
|
|
||||||
|
```
|
||||||
|
git -C P_base fetch <fork-clone-url> '+refs/heads/*:refs/fork/<owner-hex>/<sanitized-id>/*'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `refs/fork/…` keeps imported refs away from `refs/remotes/*` and
|
||||||
|
`refs/heads/*`, so the repo browser, `repo_branches` and DWIM checkout never
|
||||||
|
see them.
|
||||||
|
- Fetch tries each announced `clone` URL until one works (`grasp://` →
|
||||||
|
`https://` rewrite, `GIT_TERMINAL_PROMPT=0`), like `clone_repo` /
|
||||||
|
`push_commit_ref`.
|
||||||
|
- Switching fork or refreshing: prune the old `refs/fork/<owner>/<id>/*`
|
||||||
|
prefix first (`git update-ref --stdin` fed by `for-each-ref`), then
|
||||||
|
re-import. All-heads import in one fetch; subsequent branch switches within
|
||||||
|
the same fork are offline.
|
||||||
|
- Range work uses full refs: `merge_base(P_base,
|
||||||
|
"refs/remotes/origin/<base>", "refs/fork/…/<compare>")`, then the existing
|
||||||
|
`worktree_commit_range_commits`/`worktree_commit_range_diff` /
|
||||||
|
`format_patch_between`. None of these touch the checkout state.
|
||||||
|
- Base `main` and fork `main` are different refs: the "choose different
|
||||||
|
branches" guard compares full refs, display names stay short.
|
||||||
|
|
||||||
|
### 2.3 Fork discovery
|
||||||
|
|
||||||
|
Candidates = `RepoListStore::global(cx).read(cx).announcements` (already
|
||||||
|
deletion-filtered, latest-wins) where
|
||||||
|
`Announcement::is_fork_of(base_addr, base_euc)`:
|
||||||
|
|
||||||
|
- `upstream.addr == Some(base_addr)` (the `u` tag — also covers permanent
|
||||||
|
forks whose EUC changed), **or**
|
||||||
|
- `euc == base announcement's euc` (shared earliest-unique-commit family),
|
||||||
|
excluding the base repo itself.
|
||||||
|
|
||||||
|
Ordering (identity-coherent, ngit-style): **your own forks first** (30617
|
||||||
|
owner == signed-in user), then other authors' related repos (same mechanics,
|
||||||
|
marked, niche). Announcements without `clone` URLs are excluded (unfetchable).
|
||||||
|
Restricting to your own forks only later is a one-line ownership filter.
|
||||||
|
|
||||||
|
### 2.4 Panel behavior
|
||||||
|
|
||||||
|
- Defaults mirror `apply_checkout`: base = announced `store.head` if present in
|
||||||
|
mirror branches, else `main`, else first; compare = fork's `main`, else
|
||||||
|
first fork branch.
|
||||||
|
- `submit`: `format_patch_between(P_base, merge_base, compare_ref)`; published
|
||||||
|
`branch-name` = compare short name; `push_from = Some(P_base)` (fork objects
|
||||||
|
are there after import). Publishing itself is workstream B.
|
||||||
|
- `open_commit_diff` uses `P_base` in fork mode.
|
||||||
|
- Errors: no common ancestor → existing message; unreachable base mirror or
|
||||||
|
fork → inline error; empty range → existing "no commits to propose".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workstream B — GRASP-06 author hosting
|
||||||
|
|
||||||
|
Applies to **every** PR publish from a repo path that has the objects —
|
||||||
|
checkout mode and fork mode alike. `RepoStore::open_pull_request` keeps its
|
||||||
|
signature; internals change:
|
||||||
|
|
||||||
|
1. **Resolve author grasp servers** (new shared helper): latest kind-10317
|
||||||
|
grasp list of the signed-in user from the local DB (`filters::grasp_list`,
|
||||||
|
`g` tags in order) → **fallback to settings defaults**
|
||||||
|
(`GraspServersSettings.default_servers` / `DEFAULT_GRASP_SERVERS`, the same
|
||||||
|
source the create-repo dialogs use) when no list is published.
|
||||||
|
2. **Build `/prs/` URLs**: `grasp_base_url(server) + "/prs/" + user_npub +
|
||||||
|
"/" + base_repo_id + ".git"` (npub form, like ngit; `grasp_base_url` maps
|
||||||
|
wss→https, ws→http).
|
||||||
|
3. **`clone` tag** = dedup of `/prs/` URLs plus the current base-announcement
|
||||||
|
clone URLs (order: `/prs/` first — the author's servers are the most likely
|
||||||
|
to be alive and author-controlled).
|
||||||
|
4. **Push loop** = author `/prs/` servers first (guaranteed writable — the
|
||||||
|
point of GRASP-06), then the base announcement grasp servers (existing
|
||||||
|
behavior), all `refs/nostr/<event-id>` from `push_from`. Best-effort;
|
||||||
|
zero successes → existing `last_warning` banner; publishing always
|
||||||
|
proceeds.
|
||||||
|
|
||||||
|
Effect: a repo announced with relays but no reachable grasp hosting still gets
|
||||||
|
a downloadable tip (on the author's own hosting), and git-native clients
|
||||||
|
(ngit, `git-remote-nostr`) can fetch Signed PR tips from the `clone` URL.
|
||||||
|
|
||||||
|
**1619 updates are out of scope for v1**: the update dialog is paste-only, so
|
||||||
|
no repo path holds the new tip's objects. Deferred until the existing
|
||||||
|
"local-checkout generation for the update-PR dialog" TODO lands; then push the
|
||||||
|
new tip to the same `/prs/` set under the PR's stable ref
|
||||||
|
(`refs/nostr/<root-pr-event-id>`, advanced per revision — convention to verify
|
||||||
|
against ngit-grasp first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Workstream C — Checkout associations & suggestions
|
||||||
|
|
||||||
|
Three tiers: **Remember → Auto-pick → Suggest**.
|
||||||
|
|
||||||
|
### 4.1 Remember (associations)
|
||||||
|
|
||||||
|
A local folder ↔ announced repo association comes from two sources:
|
||||||
|
|
||||||
|
- **Explicit** (persistent settings records `{path, addr, last_used}`):
|
||||||
|
recorded when the repo header **Clone** action succeeds (addr known) and
|
||||||
|
when a folder pick succeeds in the New PR panel (store addr known).
|
||||||
|
- **Implicit** (derived, no persistence): among `LocalReposStore` scan results
|
||||||
|
(settings `local_repos.scan_paths`), a repo whose
|
||||||
|
- `origin` URL matches an announcement `clone` URL (compare host+path,
|
||||||
|
ignoring scheme: ws/wss/http/https/grasp are equivalent transports of the
|
||||||
|
same grasp URL), or
|
||||||
|
- root commit equals the announcement EUC
|
||||||
|
is a checkout of that announced repo.
|
||||||
|
|
||||||
|
Resolution order per repo: remembered (freshest first) ∪ scanned-matched,
|
||||||
|
deduplicated by path, skipping missing directories.
|
||||||
|
|
||||||
|
### 4.2 Auto-pick (New PR panel prefill)
|
||||||
|
|
||||||
|
`open_new_pull_panel(…)` gains a suggested-checkout parameter, resolved by the
|
||||||
|
caller from the association store:
|
||||||
|
|
||||||
|
- **Exactly one** checkout → auto-apply it: selectors populate, base =
|
||||||
|
announced HEAD, compare = current branch, diff loads. The folder button
|
||||||
|
becomes "Change…".
|
||||||
|
- **Several** → a small folder combobox instead of the modal folder picker.
|
||||||
|
- **None** → today's flow unchanged.
|
||||||
|
- Successful manual folder picks are recorded back (learning).
|
||||||
|
|
||||||
|
### 4.3 Suggest (status + surfaces)
|
||||||
|
|
||||||
|
A small checkout-status computation (part of the association store), scoped to
|
||||||
|
the bounded set of associated checkouts, on background threads:
|
||||||
|
|
||||||
|
- Triggers: app open, window focus (debounced ~5 s), `LocalReposStore` rescan,
|
||||||
|
`BackendEvent::Synced`.
|
||||||
|
- Per checkout: current branch; commits ahead of the base branch (announced
|
||||||
|
HEAD name if present locally, else `main`, else first local branch — the
|
||||||
|
same rule as `apply_checkout`), via `rev-list --count`; whether the user has
|
||||||
|
an **open** PR from that branch on the target repo (author == me,
|
||||||
|
`branch-name` tag == branch, fallback: tip `c` tag == local HEAD).
|
||||||
|
- Result states: `ReadyToCreate { target, branch, ahead, base }` /
|
||||||
|
`HasOpenPr { … }` / `Idle`.
|
||||||
|
- Noise rules: only when ahead > 0 and branch ≠ base; nothing for dirty
|
||||||
|
worktrees; one entry per target repo.
|
||||||
|
|
||||||
|
Surfaces:
|
||||||
|
|
||||||
|
| Surface | Shows | Dedupe data source | Scope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Repo **PR list** banner (`PullRequestsView`) | "branch `feature` is 3 commits ahead of `main` — Create pull request →" (opens prefilled New PR) | live open `RepoStore` (precise) | v1 |
|
||||||
|
| **Sidebar** "Ready to contribute" group | row per `ReadyToCreate`: target repo, branch ↑N → opens target repo + prefilled New PR | v1: only targets with a live open store, else a local-DB query refreshed after a lazy per-target bootstrap activity sync; if the repo has no data yet, the group omits it (no false "ready") | v2 (after v1 proves out) |
|
||||||
|
| Repo detail header chip | tiny `feature ↑3` on the repo whose checkout is ahead | as PR-list banner | optional |
|
||||||
|
|
||||||
|
The PullRequestsView banner reuses the existing dismissible `Alert` banner
|
||||||
|
pattern already used for store errors/warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Combined flow (fork mode, end to end)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User (New PR panel)
|
||||||
|
participant P as Base mirror (GitCache)
|
||||||
|
participant F as Fork grasp server
|
||||||
|
participant A as Author grasp (GRASP-06 /prs/)
|
||||||
|
participant B as Base repo grasps
|
||||||
|
participant R as Nostr relays
|
||||||
|
|
||||||
|
U->>U: pick fork repo (u/EUC relation, yours first) + branch
|
||||||
|
U->>P: ensure_clone(base) + fetch_all
|
||||||
|
P-->>F: fetch +refs/heads/*:refs/fork/<owner>/<id>/*
|
||||||
|
P-->>U: base branches (origin/*) + fork branches (refs/fork/…)
|
||||||
|
U->>P: merge-base, range commits, range diff (Files/Commits tabs)
|
||||||
|
U->>P: submit: format-patch merge-base..fork-ref
|
||||||
|
U->>R: publish kind-1617 series (root + NIP-10 chain, ≤60 KB each)
|
||||||
|
U->>R: sign kind-1618 (a=base, c=fork tip, merge-base, branch-name, clone=[/prs/…, base clone URLs], e=root patch, r=EUC)
|
||||||
|
U->>A: push tip → refs/nostr/<event-id> (author servers, first)
|
||||||
|
U->>B: push tip → refs/nostr/<event-id> (best-effort redundancy)
|
||||||
|
U->>R: publish kind-1618
|
||||||
|
Note over R: zero successful pushes → last_warning banner only
|
||||||
|
```
|
||||||
|
|
||||||
|
Checkout mode is identical except the fork-import step; suggestions (workstream
|
||||||
|
C) only add entry-point shortcuts into this flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Step-by-step implementation
|
||||||
|
|
||||||
|
Phases are ordered so each step lands on green: foundations first, then the
|
||||||
|
publish-side change (benefits the existing checkout flow immediately), then
|
||||||
|
the fork UI, then the UX layer. Every step compiles, passes its tests, and
|
||||||
|
keeps existing behavior unchanged.
|
||||||
|
|
||||||
|
### Phase 0 — Foundations
|
||||||
|
|
||||||
|
#### Step 1 — `signed_core`: fork relation predicate
|
||||||
|
|
||||||
|
- File: `crates/signed_core/src/model.rs`.
|
||||||
|
- Add `Announcement::is_fork_of(&self, base: &RepoAddr, base_euc:
|
||||||
|
Option<&str>) -> bool`:
|
||||||
|
`upstream.addr == Some(base)` OR (`base_euc` present AND `self.euc ==
|
||||||
|
base_euc`), excluding self (same owner + id).
|
||||||
|
- Tests: u-tag coordinate match; shared EUC match; permanent fork with
|
||||||
|
different EUC matched via `u`; no-match; base-self exclusion.
|
||||||
|
- Done when: predicate + tests green; used by Step 5.
|
||||||
|
|
||||||
|
#### Step 2 — `signed_git`: mirror/import primitives
|
||||||
|
|
||||||
|
- File: `crates/signed_git/src/lib.rs`.
|
||||||
|
- `fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) ->
|
||||||
|
Result<()>` — CLI `git fetch <url> <refspec>`, grasp:// → https rewrite,
|
||||||
|
`GIT_TERMINAL_PROMPT=0`, try each URL until one works (last-error on all
|
||||||
|
failing, like `clone_repo`).
|
||||||
|
- `refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>>` —
|
||||||
|
full refnames under `prefix` (`git for-each-ref --format=%(refname)`),
|
||||||
|
sorted.
|
||||||
|
- `delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()>` —
|
||||||
|
collect via `for-each-ref`, delete via `git update-ref --stdin` lines.
|
||||||
|
- `origin_url(workdir: &Path) -> Result<Option<String>>` (used by Step 7).
|
||||||
|
- Tests (existing `file://` fixture infra): import under a target prefix;
|
||||||
|
URL fallback to the working server; prefix listing; prefix deletion;
|
||||||
|
origin URL read.
|
||||||
|
- Done when: helpers + tests green.
|
||||||
|
|
||||||
|
### Phase 1 — GRASP-06 hosting (publish side)
|
||||||
|
|
||||||
|
#### Step 3 — author grasp-server resolution
|
||||||
|
|
||||||
|
- File: `crates/signed_state/src/backend.rs` (or a small new module).
|
||||||
|
- `resolve_user_grasp_servers(cx, user) -> Vec<RelayUrl>`: latest kind-10317
|
||||||
|
of `user` from the local DB (`filters::grasp_list`; latest event wins; `g`
|
||||||
|
tags in order) → fallback to settings `GraspServersSettings`
|
||||||
|
defaults/`DEFAULT_GRASP_SERVERS` when the user has no grasp list.
|
||||||
|
- Refactor the create-repo/init dialogs to share it (optional, keeps one
|
||||||
|
resolution path).
|
||||||
|
- Tests: latest-wins selection; missing list falls back; `g` order preserved.
|
||||||
|
- Done when: helper + tests green.
|
||||||
|
|
||||||
|
#### Step 4 — `open_pull_request` hosting
|
||||||
|
|
||||||
|
- File: `crates/signed_state/src/repo.rs` (`open_pull_request`, ~L657).
|
||||||
|
- Pure helpers (unit-testable): `grasp06_prs_url(base_url: &str, npub:
|
||||||
|
&str, repo_id: &str) -> String`; push-target assembly (author `/prs/`
|
||||||
|
URLs first, then announcement grasp URLs; dedup).
|
||||||
|
- `clone` tag = `/prs/` URLs (author npub from `Backend::current_user()`,
|
||||||
|
repo id from `self.addr().identifier`) + base announcement clone URLs.
|
||||||
|
- Push loop extended: existing per-relay loop stays; author servers push to
|
||||||
|
the `/prs/` URL instead of the repo URL. Warning semantics unchanged.
|
||||||
|
- Tests: URL building; target order; dedup. Behavioral coverage of the full
|
||||||
|
publish is manual/e2e (see §8) until a harness exists.
|
||||||
|
- Done when: checkout-mode PRs push to the author's grasp list
|
||||||
|
(`/prs/<npub>/<repo-id>.git`) first and the `clone` tag carries those URLs;
|
||||||
|
all-servers-fail still publishes with a warning.
|
||||||
|
|
||||||
|
### Phase 2 — Fork-aware compare
|
||||||
|
|
||||||
|
#### Step 5 — fork candidates
|
||||||
|
|
||||||
|
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs` (helper)
|
||||||
|
or `crates/signed_state`.
|
||||||
|
- `fork_candidates(cx) -> Vec<Announcement>`: filter
|
||||||
|
`RepoListStore::global().announcements` with `is_fork_of` (Step 1); exclude
|
||||||
|
empty `clone`; sort own forks (owner == current user) first, then others,
|
||||||
|
each group by recency/name. Re-read each time the picker opens.
|
||||||
|
- Done when: helper returns the expected ordering for a mixed list.
|
||||||
|
|
||||||
|
#### Step 6 — New PR panel fork mode
|
||||||
|
|
||||||
|
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs`.
|
||||||
|
- State: `CompareSource { Checkout, Fork { announcement } }`; per-mode item
|
||||||
|
sets for both selectors; `mirror_path`; fork branch list; full-ref
|
||||||
|
base/compare tracking (display keeps short names).
|
||||||
|
- `choose_fork(announcement)` + `prepare_fork` (mirror `choose_checkout` /
|
||||||
|
`apply_checkout` async shape, `compare_generation` guard): ensure base
|
||||||
|
mirror (`ensure_clone` + `fetch_all`), prune previous `refs/fork/…`
|
||||||
|
prefix, import fork heads (`fetch_repo_refs`), list both ref sets
|
||||||
|
(`refs_with_prefix`), populate selectors with defaults, `reload_compare`.
|
||||||
|
- `reload_compare` / `submit` / `open_commit_diff` become mode-aware (path +
|
||||||
|
base ref + compare ref resolution; `format_patch_between` and
|
||||||
|
`push_from` on `P_base`; published `branch-name` = short name).
|
||||||
|
- UI (compare bar): source control next to "Pull From" (local checkout /
|
||||||
|
announced fork), fork-repo combobox (grouped, own forks first), refresh
|
||||||
|
affordance, "Change…" back to checkout; loading spinner; inline errors.
|
||||||
|
- Done when: checkout mode is byte-identical in behavior; fork mode shows
|
||||||
|
base/fork selectors, Files/Commits tabs, commit diffs, and publishes with
|
||||||
|
the correct tags (manual §8).
|
||||||
|
|
||||||
|
### Phase 3 — Checkout associations & suggestions
|
||||||
|
|
||||||
|
#### Step 7 — association store
|
||||||
|
|
||||||
|
- Files: `crates/settings` (extend the settings model like
|
||||||
|
`local_repos.scan_paths` with remembered checkouts
|
||||||
|
`{path, addr, last_used}`); new `crates/signed_state/src/checkouts.rs`
|
||||||
|
(global store, `Arc` + debounce pattern from `LocalReposStore`/
|
||||||
|
`RepoListStore`).
|
||||||
|
- API: `associations_for(addr) -> Vec<PathBuf>` (remembered freshest-first ∪
|
||||||
|
scanned-matched by origin URL/EUC via Step 2's `origin_url` +
|
||||||
|
`signed_git::root_commit`; scheme-insensitive URL compare; dedup; skip
|
||||||
|
missing dirs); `record(path, addr)`.
|
||||||
|
- Recording hooks: repo header `clone_to_folder` success
|
||||||
|
(`repo_detail/mod.rs`) and `choose_checkout` success (panel).
|
||||||
|
- Tests: matching by origin URL (scheme variants), by EUC, no match; dedup
|
||||||
|
and ordering.
|
||||||
|
- Done when: associations resolve correctly and persist.
|
||||||
|
|
||||||
|
#### Step 8 — New PR prefill
|
||||||
|
|
||||||
|
- Files: `new_pull_request.rs` (`open_new_pull_panel` + `new`); callers
|
||||||
|
`repo_detail/mod.rs` header and `pull_requests.rs`.
|
||||||
|
- Entry param `suggested_checkout: Option<PathBuf>` (default `None`);
|
||||||
|
panel applies it on construction when the folder still exists, else falls
|
||||||
|
back to the empty state. When several candidates exist the caller passes
|
||||||
|
the freshest and the panel offers the others through a folder combobox
|
||||||
|
(new small control next to the source button).
|
||||||
|
- Done when: opening New PR on a repo with a remembered/matched checkout
|
||||||
|
never shows the folder dialog; manual picks get remembered.
|
||||||
|
|
||||||
|
#### Step 9 — status computation + PR-list banner
|
||||||
|
|
||||||
|
- Files: `checkouts.rs` (status states + triggers + debounce), workspace
|
||||||
|
`pull_requests.rs` (banner), `new_pull_request.rs` (accepts the banner's
|
||||||
|
"create" click by opening prefilled).
|
||||||
|
- Status rules from §4.3; banner dedupe against the live open `RepoStore`
|
||||||
|
(author + `branch-name`, fallback tip match, open status only).
|
||||||
|
- Done when: after committing on an associated checkout and opening the
|
||||||
|
target repo's PR list, the banner appears exactly when ahead > 0 and no
|
||||||
|
open PR exists, and disappears after creating/merging/evening.
|
||||||
|
|
||||||
|
#### Step 10 — sidebar "Ready to contribute" group (v2, optional)
|
||||||
|
|
||||||
|
- File: `crates/workspace/src/views/sidebar/mod.rs` (+ `checkouts.rs`
|
||||||
|
support).
|
||||||
|
- Only list targets with reliable dedupe data (live open store, else a
|
||||||
|
local-DB activity query refreshed after a lazy per-target bootstrap
|
||||||
|
activity sync); omit everything uncertain. Clicking a row opens the target
|
||||||
|
repo (`open_repo_panel`) + prefilled New PR.
|
||||||
|
- Done when: rows appear without false "ready" entries (dedupe-uncertain
|
||||||
|
targets omitted).
|
||||||
|
|
||||||
|
### Phase 4 — Docs & validation
|
||||||
|
|
||||||
|
#### Step 11 — documentation and final validation
|
||||||
|
|
||||||
|
- Update `docs/PR_FLOW.md`: fork compare path, GRASP-06 server set + clone
|
||||||
|
tag, suggestion surfaces; the mermaid sequence in §5.
|
||||||
|
- Update `docs/TODO.md`: tick "Fork-aware compare…", "GRASP-06 …"; add the
|
||||||
|
checkout-suggestions item; keep deferred items (1619 update push, sidebar
|
||||||
|
group, reading-side clone-URL fetch) explicit.
|
||||||
|
- Run the manual validation checklist (§8) end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error handling & edge cases (all inline or warnings, as today)
|
||||||
|
|
||||||
|
- Base mirror unreachable / no base `clone` URLs → panel error in fork mode;
|
||||||
|
checkout mode unaffected.
|
||||||
|
- Fork unreachable / without `clone` URLs (excluded from candidates) → panel
|
||||||
|
error.
|
||||||
|
- No common ancestor → existing error (range flow needs shared history; Send
|
||||||
|
Patch remains the fallback).
|
||||||
|
- Author has no 10317 list and no default servers → GRASP-06 adds nothing;
|
||||||
|
today's warning stands.
|
||||||
|
- Author's grasp server does not implement `/prs/` → its push fails silently
|
||||||
|
in the loop; its URL in `clone` is inert; base grasps still tried.
|
||||||
|
- Fork branch deleted upstream / fork switched → prune prefix + re-import;
|
||||||
|
generation guard discards stale compares.
|
||||||
|
- Concurrency on `P_base` with the repo browser: we never checkout; git ref
|
||||||
|
locks make overlapping fetches safe (same class as today's browser refresh).
|
||||||
|
- Multiple checkouts of one repo → freshest first, "Change…"/combobox for the
|
||||||
|
rest.
|
||||||
|
- Branch renamed after a PR → dedupe falls back to tip-commit matching;
|
||||||
|
otherwise a duplicate suggestion may appear once (accepted v1 tradeoff).
|
||||||
|
- Suggestions never block UI; results arrive as `Arc` swaps.
|
||||||
|
|
||||||
|
## 8. Non-goals / deferred (explicitly out of scope)
|
||||||
|
|
||||||
|
- 1619 update hosting (depends on the local-checkout update-dialog TODO).
|
||||||
|
- Paste/Send-Patch flow keeps no git push (no object store; patches = truth;
|
||||||
|
no scratch-apply resurrection).
|
||||||
|
- Reading side: fetching other clients' PR tips from `clone` URLs into the
|
||||||
|
mirror (`ngit pr checkout` analog) — only needed for patch-less PRs.
|
||||||
|
- Fork creation UI (Signed still cannot announce forks; they come from ngit or
|
||||||
|
by publishing a clone) — fork candidates simply won't include non-existent
|
||||||
|
ones.
|
||||||
|
- GitHub-isms rejected: no fork-network browser, no per-fork PR pages, no
|
||||||
|
fork identity in events, no "compare across forks" for strangers' branches
|
||||||
|
beyond what is listed above.
|
||||||
|
- The panel never auto-submits anything; suggestions only navigate and
|
||||||
|
prefill.
|
||||||
|
|
||||||
|
## 9. Validation checklist (manual e2e)
|
||||||
|
|
||||||
|
1. Checkout mode regression: clone a repo to disk, branch + commit (external
|
||||||
|
git), New PR → choose folder → diff/commits → Create → PR appears on the
|
||||||
|
target repo's PR list; tip pushed to the author's `/prs/` server(s) from
|
||||||
|
the 10317 list (fallback: defaults); `clone` tag lists `/prs/` URLs first.
|
||||||
|
2. All grasp servers down/absent → PR still publishes; warning banner shows.
|
||||||
|
3. Fork mode: with a fork announcement related to the base (own fork first,
|
||||||
|
other author's fork listed), pick repo + branch → selectors, Files/Commits
|
||||||
|
tabs, commit-diff rows correct; published 1618 carries `a` = base
|
||||||
|
coordinate, `c` = fork tip, `merge-base` = fork point, `branch-name` =
|
||||||
|
fork branch; tip fetchable from the advertised `/prs/` URL via a plain
|
||||||
|
`git fetch`.
|
||||||
|
4. Prefill: reopen New PR for the same repo → folder auto-chosen, selectors
|
||||||
|
populated; "Change…" works.
|
||||||
|
5. Banner: with the repo's PR list open and an associated checkout ahead with
|
||||||
|
no open PR → banner appears; disappears after publishing a PR, after
|
||||||
|
merging, and when the branch is even.
|
||||||
|
6. Interop: an ngit/git client fetches a Signed PR's tip from the `/prs/`
|
||||||
|
clone URL (requires a GRASP-06-enabled server).
|
||||||
|
|
||||||
|
## 10. Implementation log (2026-09-03, branch `feat/fork`)
|
||||||
|
|
||||||
|
All steps below landed with unit tests; `cargo test` across `signed_core`
|
||||||
|
(44), `signed_git` (61), `signed_state` (15), `workspace` (14) and
|
||||||
|
`settings` (9) is green, and `cargo check` on the whole workspace passes.
|
||||||
|
The manual e2e checklist above still needs a real GRASP-06 server run.
|
||||||
|
|
||||||
|
- **Step 1** — `Announcement::is_fork_of` (`signed_core::model`) + 4 tests.
|
||||||
|
- **Step 2** — `signed_git`: `fetch_repo_refs`, `refs_with_prefix`,
|
||||||
|
`delete_refs_with_prefix`, `origin_url`, `GitCache::root()` + 4 tests
|
||||||
|
(import/list/prune against `file://` fixtures incl. URL fallback).
|
||||||
|
- **Step 3** — backend grasp-list resolution: `grasp_list_servers`,
|
||||||
|
`latest_grasp_list_servers`, `user_grasp_list_servers` (DB query, latest
|
||||||
|
wins) + `grasp06_prs_url` and `pr_clone_urls` (author-first, dedup) + 4
|
||||||
|
tests. `signed_state` gained a `settings` dependency for the defaults
|
||||||
|
fallback.
|
||||||
|
- **Step 4** — `RepoStore::open_pull_request` (signature unchanged):
|
||||||
|
resolves the author's grasp servers (10317 → settings defaults) inside
|
||||||
|
the publish task, builds the `clone` tag from `/prs/` URLs first, pushes
|
||||||
|
author `/prs/` targets before the base announcement's servers, deduped;
|
||||||
|
all-fail keeps the `last_warning` banner. 1619 updates untouched
|
||||||
|
(deferred, as planned).
|
||||||
|
- **Step 5** — `fork_candidates` ordering helper + 2 tests (own forks
|
||||||
|
first; base/unrelated/no-clone excluded; EUC-less base still matches via
|
||||||
|
`u`).
|
||||||
|
- **Step 6** — New PR panel fork mode: `ForkCompare` state, `choose_fork`/
|
||||||
|
`apply_fork` (mirror `ensure_clone` → prune `refs/fork` → import → list
|
||||||
|
both ref sets), mode-aware `base_ref`/`compare_ref`/`work_path` used by
|
||||||
|
`reload_compare`/`submit`/`open_commit_diff`, stale-result guard,
|
||||||
|
refresh-by-re-picking + refresh button, and a "Source" picker menu
|
||||||
|
(checkout rows + forks) replacing the folder button. Checkout mode stays
|
||||||
|
byte-identical in behavior. Deviations from the plan: selectors and the
|
||||||
|
source picker keep one shared layout (no separate fork-repo combobox —
|
||||||
|
the source menu lists forks grouped own-first, matching the ordering
|
||||||
|
requirement); `IconName::GitBranch` does not exist upstream so fork rows
|
||||||
|
use the project's `CustomIconName::GitBranch`.
|
||||||
|
- **Step 7** — settings `CheckoutRecord`/`CheckoutsSettings` group + new
|
||||||
|
`signed_state::checkouts::CheckoutsStore` global (observe settings /
|
||||||
|
local scan / announcements; debounced, coalesced, Arc-swapped):
|
||||||
|
scheme-insensitive `same_repo_url`, `resolve_associations` (remembered
|
||||||
|
freshest-first ∪ scanned origin/EUC matches, dedup, mirror-cache paths
|
||||||
|
excluded), `record()`, per-repo `request_statuses`/`statuses_of` with
|
||||||
|
`CheckoutStatus` (branch/head/base/ahead; dirty and detached checkouts
|
||||||
|
never suggested; 15 s poll while any PR list is open). 7 tests.
|
||||||
|
Deviations: settings records store the address as a string (the settings
|
||||||
|
crate stays free of nostr types); mirror exclusion uses the cache root
|
||||||
|
(new `GitCache::root()`); statuses are computed per requested repo with
|
||||||
|
the announced HEAD supplied by the open list panel rather than from a
|
||||||
|
30618 DB query.
|
||||||
|
- **Step 8** — New PR panel prefills the freshest associated checkout on
|
||||||
|
construction (no folder dialog); `apply_folder_path` applies a given
|
||||||
|
path; successful folder picks and header clones are recorded back; the
|
||||||
|
Source menu lists associated checkouts (checked when applied) plus
|
||||||
|
"Choose another folder…". Deviations: instead of a separate folder
|
||||||
|
combobox, the alternatives live in the Source menu (fewer controls, same
|
||||||
|
outcome); `open_new_pull_panel` needed no signature change because the
|
||||||
|
panel reads the association store itself.
|
||||||
|
- **Step 9** — "ready to contribute" banner: `RepoDetailView` requests
|
||||||
|
the statuses while the repository panel is open (re-requested when the
|
||||||
|
announced HEAD lands or changes) and renders the banner under the repo
|
||||||
|
header; the first ready checkout not covered by an open PR of the
|
||||||
|
signed-in user (`branch-name`, fallback `c`-tag tip) and not dismissed
|
||||||
|
(per-panel dismissal set) is offered with a Create button opening the
|
||||||
|
prefilled panel. The dedupe predicate is the tested
|
||||||
|
`pr_proposes_checkout` in `signed_state::checkouts`. Deviation from the
|
||||||
|
plan: the surface is the repository panel (not the PR-list panel, as the
|
||||||
|
user requested after v1; the PR list keeps only its error/warning
|
||||||
|
banners), and there is no window-focus trigger (no precedent in the
|
||||||
|
codebase; the 15 s poll plus open/rescan/settings triggers cover the
|
||||||
|
plan's "done when" cases).
|
||||||
|
- **Step 10** — deferred (v2, optional), per plan; the banner is the v1
|
||||||
|
surface.
|
||||||
|
- **Step 11** — `docs/PR_FLOW.md` rewritten for the current panel flow
|
||||||
|
(fork import, GRASP-06 hosting, suggestions, deferred items explicit);
|
||||||
|
`docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run
|
||||||
|
against a live GRASP-06 server.
|
||||||
+71
-37
@@ -11,23 +11,22 @@ state.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A["New pull request dialog"] --> B{"Patch source"}
|
A["New pull request panel"] --> B{"Compare source"}
|
||||||
B -->|"Paste"| C["Paste git format-patch output"]
|
B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"]
|
||||||
B -->|"Local checkout"| D["Browse for checkout"]
|
B -->|"Announced fork"| D["Pick fork repo + branch"]
|
||||||
D --> E["Defaults: source = current branch, target = announced HEAD"]
|
D --> D1["Ensure base mirror (GitCache), fetch origin"]
|
||||||
E --> F["Generate: merge-base plus format-patch base..tip"]
|
D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"]
|
||||||
F --> G["Apply check vs mirror clone - non-blocking warning"]
|
C --> E["Defaults: target = announced HEAD, source = current branch / fork main"]
|
||||||
C --> H["Submit"]
|
E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"]
|
||||||
F --> H
|
F --> H["Submit: format-patch base..tip at publish time"]
|
||||||
G --> H
|
|
||||||
H --> I["split_patch_series: one part per commit"]
|
H --> I["split_patch_series: one part per commit"]
|
||||||
I --> J{"Any part over 60 KB?"}
|
I --> J{"Any part over 60 KB?"}
|
||||||
J -->|"Yes"| K["Refuse with message"]
|
J -->|"Yes"| K["Refuse with message"]
|
||||||
J -->|"No"| L["tip = last part's From commit"]
|
J -->|"No"| L["tip = last part's From commit"]
|
||||||
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
||||||
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base"]
|
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base, clone"]
|
||||||
N --> O["Sign early - learn the event id"]
|
N --> O["Sign early - learn the event id"]
|
||||||
O --> P["Push tip to refs/nostr/event-id on every announced grasp server"]
|
O --> P["Push tip to refs/nostr/event-id: author /prs/ grasp servers first, then the announced servers"]
|
||||||
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
||||||
P --> R["Publish kind-1618 PR event"]
|
P --> R["Publish kind-1618 PR event"]
|
||||||
Q --> R
|
Q --> R
|
||||||
@@ -50,47 +49,81 @@ graph TD
|
|||||||
|
|
||||||
Key points of the write side:
|
Key points of the write side:
|
||||||
|
|
||||||
- **Merge base**: only computable in the local-checkout path
|
- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the
|
||||||
(`signed_git::merge_base`); the paste path publishes none. The dialog
|
wire):
|
||||||
reuses it at submit only while the patch textarea is unchanged.
|
- *Local checkout*: both branch selectors list a picked folder's
|
||||||
|
branches; all git ops run in that folder. Checkouts of the target repo
|
||||||
|
are remembered (folder pick + app clones) and matched implicitly
|
||||||
|
(origin URL or EUC against the announcement), so the panel prefills the
|
||||||
|
freshest one — no folder dialog for the common case.
|
||||||
|
- *Announced fork*: the fork's heads are fetched into the target repo's
|
||||||
|
GitCache mirror under `refs/fork/<owner-hex>/<id>/*` (private
|
||||||
|
namespace; the browser never sees them). "Merge Into" lists the
|
||||||
|
mirror's `refs/remotes/origin/*`, "Pull From" the imported fork
|
||||||
|
branches, and every git op — merge-base, range diff/commits,
|
||||||
|
format-patch, tip push — runs in the mirror, which holds both
|
||||||
|
histories. Fork candidates are announcements related to the target by
|
||||||
|
`u` tag or shared EUC, own forks first, without `clone` URLs excluded.
|
||||||
|
- **GRASP-06 hosting**: the tip is pushed under `refs/nostr/<event-id>`
|
||||||
|
(nak's convention) to the *author's* grasp servers first —
|
||||||
|
`https://<host>/prs/<author-npub>/<repo-id>.git`, resolved from the
|
||||||
|
author's kind-10317 grasp list, falling back to the settings defaults —
|
||||||
|
then to the base repository's announced grasp servers. The `clone` tag
|
||||||
|
lists those `/prs/` URLs first, then the announced clone URLs (fixed
|
||||||
|
before signing; dead URLs are inert, the patches stay the source of
|
||||||
|
truth). Contributing therefore never depends on the other project's
|
||||||
|
servers accepting a push.
|
||||||
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
||||||
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
||||||
commit of the series (the tip), and each part carries its own
|
commit of the series (the tip), and each part carries its own
|
||||||
`commit`/`r` tags.
|
`commit`/`r` tags.
|
||||||
- **Push before publish**: the tip is pushed to every announced grasp
|
- **Push before publish**: failure is non-fatal — the patch events remain
|
||||||
server under `refs/nostr/<event-id>` (nak's convention) so the announced
|
the source of truth — and surfaces as a `last_warning` banner.
|
||||||
`clone` URLs really can serve the commit. Failure is non-fatal — the
|
- **1619 updates are paste-only today** (no repo path holds the new tip's
|
||||||
patch events remain the source of truth — and surfaces as a
|
objects), so updates are not pushed; hosting them is deferred until the
|
||||||
`last_warning` banner.
|
update dialog gains a local-checkout source.
|
||||||
|
|
||||||
## Creating a pull request - event ordering
|
## Creating a pull request - event ordering
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant User
|
participant User
|
||||||
participant App
|
participant P as Base mirror (GitCache)
|
||||||
participant Checkout as Local checkout
|
participant F as Fork grasp server
|
||||||
participant Grasp as Grasp servers
|
participant A as Author grasp (GRASP-06 /prs/)
|
||||||
participant Relays as Nostr relays
|
participant B as Base repo grasps
|
||||||
|
participant R as Nostr relays
|
||||||
|
|
||||||
User->>App: pick checkout and branches, Generate
|
User->>P: ensure mirror (fork mode) / pick local checkout
|
||||||
App->>Checkout: merge-base(source, target)
|
P-->>F: fetch fork heads -> refs/fork/... (fork mode)
|
||||||
Checkout-->>App: base commit
|
User->>P: merge-base, range commits, range diff
|
||||||
App->>Checkout: format-patch base..tip
|
User->>P: submit: format-patch base..compare-ref
|
||||||
Checkout-->>App: patch series
|
|
||||||
App->>App: split series, check per-part size
|
|
||||||
loop each patch of the series
|
loop each patch of the series
|
||||||
App->>Relays: publish kind-1617 (first: t root, later: e reply)
|
User->>R: publish kind-1617 (first: t root, later: e reply)
|
||||||
end
|
end
|
||||||
App->>App: build and sign kind-1618 PR event
|
User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced)
|
||||||
App->>Grasp: push tip to refs/nostr/event-id
|
User->>A: push tip to refs/nostr/event-id (author servers, first)
|
||||||
Grasp-->>App: accepted or rejected (best-effort)
|
User->>B: push tip to refs/nostr/event-id (best-effort)
|
||||||
App->>Relays: publish kind-1618 PR event
|
A-->>User: accepted or rejected (all rejected -> warning)
|
||||||
|
User->>R: publish kind-1618 PR event
|
||||||
opt draft
|
opt draft
|
||||||
App->>Relays: publish kind-1633 draft status
|
User->>R: publish kind-1633 draft status
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ready to contribute (suggestions)
|
||||||
|
|
||||||
|
Local checkouts are matched to announced repositories (remembered records
|
||||||
|
freshest-first ∪ scanned matches by origin URL or EUC). While a repository's
|
||||||
|
detail panel is open, each associated checkout is checked off the main
|
||||||
|
thread: current branch vs its base (announced HEAD, else `main`, else the
|
||||||
|
first branch), commits ahead, dirty worktrees excluded. A banner in the
|
||||||
|
repository panel then offers a prefilled New PR panel for the first branch
|
||||||
|
that is ahead with **no open PR by you** proposing it (`branch-name` tag,
|
||||||
|
falling back to the `c` tip tag) — NIP-34-native dedupe, refreshed
|
||||||
|
periodically and whenever the checkouts/announcements change. The panel
|
||||||
|
never submits anything on its own; suggestions only navigate and prefill.
|
||||||
|
|
||||||
## Updating and merging
|
## Updating and merging
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -100,7 +133,7 @@ sequenceDiagram
|
|||||||
participant Maintainer
|
participant Maintainer
|
||||||
participant Clone as Mirror clone
|
participant Clone as Mirror clone
|
||||||
|
|
||||||
Note over Author,Relays: Update - PR author only
|
Note over Author,Relays: Update - PR author only (paste flow, no push yet)
|
||||||
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
||||||
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
||||||
|
|
||||||
@@ -136,4 +169,5 @@ Reader rules that keep the flow consistent:
|
|||||||
stranger's update is ignored.
|
stranger's update is ignored.
|
||||||
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
||||||
other clients without patch events fall back to diffing
|
other clients without patch events fall back to diffing
|
||||||
`merge-base..tip` in the local clone.
|
`merge-base..tip` in the local clone. Fetching tips from `clone` URLs
|
||||||
|
(ngit `pr checkout` analog) is not implemented yet.
|
||||||
|
|||||||
+4
-2
@@ -33,8 +33,10 @@
|
|||||||
|
|
||||||
- [ ] GRASP-06 `/prs/<npub>/<id>.git` contributor endpoints + kind-10317 user grasp-list fallback.
|
- [ ] GRASP-06 `/prs/<npub>/<id>.git` contributor endpoints + kind-10317 user grasp-list fallback.
|
||||||
- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed.
|
- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed.
|
||||||
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only).
|
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only); once it lands, push update tips to the same `/prs/` set under the PR's stable ref.
|
||||||
- [ ] Fork-aware compare in the New PR panel: today both branch selectors come from the user-picked local checkout, so a cross-fork PR (GitHub's "compare across forks") requires the fork's branch to exist locally. Add picking the fork repository from announced repos (its 30617 may point at this repo via the `u` tag, or share the EUC) + a branch, fetch it into the `GitCache` mirror, and run the `merge-base`/diff/`format-patch` flow against the base repo's mirror — like `choose_checkout` today but repo-driven.
|
- [x] Fork-aware compare in the New PR panel: the compare side can come from an announced fork repository (u-tag/EUC-related, own forks first) whose branches are imported into the base repo's GitCache mirror under `refs/fork/<owner>/<id>/*`; `merge-base`/diff/`format-patch`/push all run in the mirror against full refs. The panel's source picker switches between local checkouts and announced forks.
|
||||||
|
- [x] GRASP-06 author hosting: PR tips are pushed to the author's own grasp servers under `/prs/<author-npub>/<repo-id>.git` (kind-10317 grasp list, settings defaults as fallback) before the base announcement's servers; the `clone` tag carries the `/prs/` URLs first.
|
||||||
|
- [ ] Checkout suggestions ("ready to contribute"): remembered/matched local checkouts prefill the New PR panel; a repo-panel banner suggests creating a PR when a branch is ahead with no open PR. Sidebar "Ready to contribute" group is v2 (deferred).
|
||||||
|
|
||||||
## Performance: render path
|
## Performance: render path
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user