update pull request

This commit is contained in:
2026-09-03 10:44:38 +07:00
parent 33cbe42551
commit 01f0540726
16 changed files with 3211 additions and 248 deletions
Generated
+69 -87
View File
@@ -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"
+36
View File
@@ -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,
} }
+87
View File
@@ -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]]);
+279
View File
@@ -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")
+4
View File
@@ -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"
+150
View File
@@ -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());
}
} }
+792
View File
@@ -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
));
}
}
+5 -9
View File
@@ -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
View File
@@ -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,
+165 -28
View File
@@ -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
View File
@@ -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 16301633 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
View File
@@ -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/&lt;owner&gt;/&lt;id&gt;/*"]
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
View File
@@ -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