diff --git a/Cargo.lock b/Cargo.lock index a4f693c..94ab2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,38 +14,18 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf 0.13.1", "serde", "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]] name = "accesskit_consumer" version = "0.38.0" @@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", + "accesskit_consumer", "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -72,9 +52,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -90,12 +70,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.33.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", - "accesskit_consumer 0.37.0", + "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -342,9 +322,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" dependencies = [ "compression-codecs", "compression-core", @@ -678,9 +658,9 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.13.1", "cexpr", @@ -1238,7 +1218,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "gpui_util", "indexmap", @@ -1263,9 +1243,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" dependencies = [ "bzip2", "compression-core", @@ -1275,9 +1255,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1704,7 +1684,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "proc-macro2", "quote", @@ -1866,7 +1846,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg", ] @@ -3484,7 +3464,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "anyhow", @@ -3547,7 +3527,7 @@ dependencies = [ "uuid", "waker-fn", "web-time", - "windows 0.61.3", + "windows 0.62.2", "zed-font-kit", "zed-scap", "ztracing", @@ -3556,7 +3536,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "aho-corasick", "anyhow", @@ -3592,7 +3572,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "anyhow", "chrono", @@ -3669,7 +3649,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "anyhow", "gpui", @@ -3683,7 +3663,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "proc-macro2", "quote", @@ -3693,21 +3673,21 @@ dependencies = [ [[package]] name = "gpui-fps" version = "0.1.0" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "gpui", - "instant", "libc", "objc2-core-foundation", "objc2-io-kit", "sysinfo 0.37.2", + "web-time", "windows 0.58.0", ] [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "block", @@ -3730,7 +3710,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_unix", @@ -3776,7 +3756,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_macos", @@ -3822,7 +3802,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3833,7 +3813,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "console_error_panic_hook", "gpui", @@ -3846,7 +3826,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "schemars", "serde", @@ -3856,7 +3836,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "log", @@ -3866,7 +3846,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3890,7 +3870,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bytemuck", @@ -3916,7 +3896,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_windows", @@ -3935,10 +3915,10 @@ dependencies = [ "raw-window-handle", "smallvec", "uuid", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry 0.5.3", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry 0.6.1", ] [[package]] @@ -4230,7 +4210,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "async-compression", @@ -4250,7 +4230,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "log", "rustls", @@ -5034,9 +5014,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" [[package]] name = "lru-slab" @@ -5206,7 +5186,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bindgen", @@ -5300,9 +5280,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -6119,9 +6099,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "open" -version = "5.4.2" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" dependencies = [ "is-wsl", "libc", @@ -6264,7 +6244,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "collections", "serde", @@ -7052,7 +7032,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "derive_refineable", ] @@ -7135,7 +7115,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bytes", @@ -7513,7 +7493,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "async-task", "backtrace", @@ -7969,9 +7949,11 @@ dependencies = [ "nostr-connect", "nostr-sdk", "rustls", + "settings", "signed_core", "signed_git", "signed_nostr", + "tempfile", "utils", ] @@ -8227,7 +8209,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "heapless 0.9.3", "log", @@ -8813,9 +8795,9 @@ dependencies = [ [[package]] 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" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -9625,7 +9607,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "perf", "quote", @@ -10505,13 +10487,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -11394,7 +11376,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "chrono", @@ -11411,7 +11393,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "tracing", "tracing-subscriber", @@ -11422,7 +11404,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" [[package]] name = "zune-core" diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 08c0ab1..edd38ee 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -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::`) 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, +} + /// The create-repository dialog. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -128,6 +162,8 @@ pub struct Settings { pub grasp_servers: GraspServersSettings, /// Local repository scanning. pub local_repos: LocalReposSettings, + /// Remembered local checkouts. + pub checkouts: CheckoutsSettings, /// The create-repository dialog. pub create_repository: CreateRepositorySettings, } diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 7a33340..3c91795 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -284,6 +284,21 @@ impl Announcement { 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. pub fn description(&self) -> SharedString { 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] fn effective_maintainers_include_owner_for_primary_repos() { let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]); diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 835ec77..54958b9 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -20,6 +20,11 @@ impl GitCache { Self { root } } + /// The root directory holding the mirror clones. + pub fn root(&self) -> &Path { + &self.root + } + /// Local path of the clone for a repository. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root @@ -523,6 +528,132 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { Ok(()) } +/// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork///*`) 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 = 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//`), sorted +/// lexicographically, like `git for-each-ref`. An empty list when nothing +/// matches. +pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { + // `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//`) 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> { + 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 /// is disabled so a credential request fails instead of hanging. fn git_in(dir: &Path, args: &[&str]) -> Result { @@ -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::::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::::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. fn git_run(dir: &Path, args: &[&str]) { let status = Command::new("git") diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index fedbe76..dc60179 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } signed_git = { path = "../signed_git" } signed_nostr = { path = "../signed_nostr" } +settings = { path = "../settings" } utils = { path = "../utils" } nostr.workspace = true @@ -24,3 +25,6 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 190130c..c7d2655 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1546,6 +1546,70 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option 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//.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, base_clone_urls: Vec) -> Vec { + 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 { + 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) -> Vec { + 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, Error> { + let events: Vec = 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 /// rejects the push is logged, but the push only fails when no server /// accepted it. `push` performs the single-server push (e.g. @@ -1628,4 +1692,90 @@ mod tests { "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![ + "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 = 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!["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!["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!["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()); + } } diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs new file mode 100644 index 0000000..bd48f1e --- /dev/null +++ b/crates/signed_state/src/checkouts.rs @@ -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); + +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>>, + /// Ready-to-contribute statuses of the requested repositories. + statuses: Arc>>, + /// Repositories whose statuses are recomputed whenever the inputs + /// change (the repository detail panels currently open). + status_requested: HashSet, + /// Announced head branch last provided per requested repository, so a + /// recompute defaults the base the same way. + requested_head: HashMap>, + refreshing: bool, + refresh_dirty: bool, + /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. + debouncing: bool, + _subscriptions: Vec, + tasks: Vec>>, +} + +impl CheckoutsStore { + /// Retrieve the global checkouts store. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, 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 { + 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) { + 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 { + 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, + cx: &mut Context, + ) { + 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 { + 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) { + 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.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 = records + .into_iter() + .filter_map(|record| { + let addr = record.addr.parse::().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)> = 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, Option)> = 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> = associations + .into_iter() + .map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect())) + .collect(); + + let mut statuses: HashMap> = HashMap::new(); + for (addr, announced_head) in &requested { + let Some(paths) = associations.get(addr) else { + continue; + }; + let list: Vec = 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, 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, Option)], + announcements: impl IntoIterator, +) -> HashMap> { + let announcements: Vec<&Announcement> = announcements.into_iter().collect(); + let mut out: HashMap> = 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 { + 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 { + 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, Option) { + ( + 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 = 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 + )); + } +} diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 25a0968..28e46e9 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,4 +1,5 @@ mod backend; +mod checkouts; mod git_store; mod local_repos; mod profile; @@ -8,6 +9,7 @@ mod repo_list; use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent}; +pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; 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. #[cfg(not(target_arch = "wasm32"))] pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) -> Entity { - // 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() .install_default() .ok(); @@ -38,15 +40,10 @@ pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) - Backend::set_global(entity.clone(), 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); - - // The clone cache is native-only; wasm registers an empty store so - // `GitStore::global` still works. GitStore::set_global(PathBuf::new(), cx); - LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); + CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); entity } @@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) - #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); GitStore::set_global(PathBuf::new(), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); - + CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); entity } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 3f482e3..372c550 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -14,7 +14,9 @@ use signed_core::{ 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; /// 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 ` header (the tip); without /// 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. - /// The `clone` tag carries the announced mirror URLs, and when - /// `push_from` is set the tip is pushed to those servers under - /// `refs/nostr/` (best-effort) before the PR is published, so - /// the commit is actually downloadable there; the linked patch stays the - /// source of truth either way. + /// + /// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first + /// (resolved from their kind-10317 grasp list, falling back to the + /// settings defaults) plus the announced mirror URLs, so the tip is + /// 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/` (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` /// publishes a kind-1633 status right after the PR event. `merge_base` @@ -704,28 +710,44 @@ impl RepoStore { let backend = Backend::global(cx); 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()); cx.notify(); 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 owner = self.addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - - let (push_owner, push_repo_id, push_relays) = self + let repo_id = addr.identifier.clone(); + let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex()); + let push_relays = self .announcement .as_ref() - .map(|a| { - let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex()); - (owner, a.id.clone(), a.relays.clone()) - }) + .map(|a| a.relays.clone()) .unwrap_or_default(); + // GRASP-06 hosting falls back to the settings defaults when + // the author has no published grasp list + let defaults: Vec = { + let settings = settings::SettingsStore::global(cx).read(cx).settings(); + let urls: Vec = 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| { - // The PR references the root patch event so viewers can find - // the patch without carrying it inline. + // The PR references the root patch event so viewers can find the patch without carrying it inline. let root_patch = match publish_patch_series( &this, cx, @@ -747,20 +769,76 @@ impl RepoStore { } }; + // GRASP-06: the tip is pushed to the author's own grasp servers + // under `/prs//.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| { + // 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 = 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 { repository: this.addr.clone(), content: description, subject, labels: Vec::new(), branch_name, - // NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded, - // 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(), + clone, current_commit, root_patch_event: Some(root_patch.id), merge_base: merge_base @@ -793,20 +871,21 @@ impl RepoStore { let path = path.clone(); let tip = tip.clone(); let reference = reference.clone(); - let owner = push_owner.clone(); - let repo_id = push_repo_id.clone(); - let relays = push_relays.clone(); + // Author servers first, then the base repository's + // announced grasp servers (best-effort redundancy). + let targets: Vec<(String, String)> = author_targets + .into_iter() + .chain(base_targets) + .collect(); async move { let mut failures = Vec::new(); let mut pushed = 0; - for relay in &relays { - let Some(base) = grasp_base_url(relay) else { - continue; - }; - let url = format!("{base}/{owner}/{repo_id}.git"); - match signed_git::push_commit_ref(&path, &url, &tip, &reference) { + for (url, label) in &targets { + match signed_git::push_commit_ref( + &path, url, &tip, &reference, + ) { Ok(()) => pushed += 1, - Err(e) => failures.push(format!("{relay}: {e}")), + Err(e) => failures.push(format!("{label}: {e}")), } } (pushed, failures) diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 403f402..c07988b 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -82,10 +82,11 @@ impl IssuesView { pub fn new( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, _window: &mut Window, cx: &mut Context, ) -> Self { + let repo_name = store.read(cx).name(); + Self { focus_handle: cx.focus_handle(), dock_area, diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index d6279ec..91b696a 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -27,9 +27,12 @@ use gpui_component::{ VirtualListScrollHandle, h_flex, v_flex, }; 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_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::{DropdownButton, PixelAvatar, UserAvatar, copy_row}; @@ -198,6 +201,17 @@ pub struct RepoDetailView { tasks: Vec>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, + /// 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, /// Upstream repository (from this fork's `u` tag) the user asked to /// open, while its announcement is still being fetched. pending_upstream: Option, @@ -219,7 +233,16 @@ impl RepoDetailView { let relays = initial.relays.clone(); 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 @@ -328,6 +351,11 @@ impl RepoDetailView { focus_handle: cx.focus_handle(), tasks: Vec::new(), _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, } } @@ -548,7 +576,7 @@ impl RepoDetailView { return; } - let (clone_urls, name) = { + let (clone_urls, name, addr) = { let Some(announcement) = self.announcement(cx) else { return; }; @@ -569,7 +597,7 @@ impl RepoDetailView { } else { name }; - (clone_urls, name) + (clone_urls, name, addr) }; self.cloning = true; @@ -599,14 +627,23 @@ impl RepoDetailView { let destination = folder.join(&name); let destination_for_open = destination.clone(); + let clone_target = destination_for_open.clone(); 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; this.update_in(cx, |this, _window, cx| { this.cloning = false; 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) => { this.error = Some(format!("Failed to clone: {error}").into()); } @@ -934,15 +971,7 @@ impl RepoDetailView { return; }; - let panel = cx.new(|cx| { - IssuesView::new( - self.dock_area.clone(), - store, - self.display_name(cx), - window, - cx, - ) - }); + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); @@ -958,15 +987,7 @@ impl RepoDetailView { return; }; - let panel = cx.new(|cx| { - PullRequestsView::new( - self.dock_area.clone(), - store, - self.display_name(cx), - window, - cx, - ) - }); + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); @@ -1798,14 +1819,127 @@ impl RepoDetailView { } let store = cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render when the store refreshes (issues, PRs, statuses). - self._subscriptions - .push(cx.observe(&store, |_this, _store, cx| cx.notify())); + // Re-render on store refreshes (issues, PRs, statuses) and keep the + // "ready to contribute" statuses of this repository requested. + self.attach_store(&store, cx); self.store = Some(store); self.initial = Some(announcement); 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, cx: &mut Context) { + 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) { + 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 { + 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) -> Option { + 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 /// HEAD commit button and the branch/tag selectors. fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { @@ -2036,6 +2170,9 @@ impl Render for RepoDetailView { .id("repo") .size_full() .child(self.render_header(cx)) + .when_some(self.render_ready_banner(cx), |this, banner| { + this.child(banner) + }) .when_some(self.error.clone(), |this, error| { this.child( Alert::error("repo-error", error) diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs index 6b7ef6c..0de4a5d 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -1,8 +1,8 @@ -//! The "new pull request" panel: pick a local checkout, a base and a -//! compare branch (GitHub-style), review the diff and the commit list, then -//! publish the PR with only a title and an optional description. The patch -//! series is generated from the checkout at submit time; there is no patch -//! input. +//! The "new pull request" panel: pick a compare source — a local checkout +//! or an announced fork of the repository — a base and a compare branch +//! (GitHub-style), review the diff and the commit list, then publish the PR +//! with only a title and an optional description. The patch series is +//! generated at submit time; there is no patch input. use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -21,6 +21,7 @@ use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; use gpui_component::scroll::Scrollbar; use gpui_component::searchable_list::SearchableVec; use gpui_component::spinner::Spinner; @@ -28,10 +29,13 @@ use gpui_component::{ ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use nostr::prelude::*; +use signed_core::{Announcement, RepoAddr}; use signed_git::{ - format_patch_between, merge_base, worktree_commit_range_commits, worktree_commit_range_diff, + delete_refs_with_prefix, fetch_repo_refs, format_patch_between, merge_base, refs_with_prefix, + sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff, }; -use signed_state::RepoStore; +use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; use signed_ui::placeholder; use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; @@ -39,10 +43,18 @@ use super::diff::{CommitDiffView, DiffPane}; /// The "new pull request" panel of a repository. /// -/// Both branch selectors list the branches of a user-chosen local checkout; -/// the compare view (Files/Commits tabs) is built from `merge-base..compare` -/// in that checkout, and the patch series published with the PR is generated -/// from the same range at submit time. +/// The compare side of the PR comes from one of two sources: +/// +/// - **Local checkout**: both branch selectors list a user-picked local +/// checkout's branches; git ops and the tip push run in the checkout. +/// - **Announced fork**: the fork's branches are fetched into the target +/// repository's GitCache mirror under `refs/fork///*`, the base +/// selector lists the mirror's `refs/remotes/origin/*` branches, and all +/// git ops run in the mirror. +/// +/// The compare view (Files/Commits tabs) is built from `merge-base..compare` +/// of the chosen refs, and the patch series published with the PR is +/// generated from the same range at submit time. pub struct NewPullRequestView { focus_handle: FocusHandle, /// Dock area the panel lives in; commit diffs are opened there. @@ -51,14 +63,17 @@ pub struct NewPullRequestView { store: Entity, /// Display name of the repository, for the panel title. repo_name: SharedString, - /// The user's checkout: where both branches live and where the tip is - /// pushed from. + /// The user's local checkout: where both branches live in checkout mode + /// and where the tip is pushed from. `None` until a folder is picked. repo_path: Option, - /// Branches of the checkout, backing both selectors. + /// Branches of the checkout, backing both selectors in checkout mode. branches: Vec, - /// Selected base branch (the target of the PR). + /// Fork-backed compare state; `Some` switches the panel into fork mode + /// (the checkout above is kept so the user can switch back). + fork: Option, + /// Selected base branch (the target of the PR), short name. base: SharedString, - /// Selected compare branch (the source of the PR). + /// Selected compare branch (the source of the PR), short name. compare: SharedString, base_select: Entity>>, compare_select: Entity>>, @@ -89,6 +104,209 @@ pub struct NewPullRequestView { tasks: Vec>>, } +/// A fork-backed compare: the fork's heads are imported into the target +/// repository's GitCache mirror under `refs/fork//*`, and the +/// mirror's own `refs/remotes/origin/*` track the base branches. +struct ForkCompare { + /// Fork announcement the compare branch is imported from. + announcement: Announcement, + /// Import namespace: `/`. + namespace: String, + /// Path of the target repository's GitCache mirror. + mirror_path: PathBuf, +} + +impl ForkCompare { + /// The full ref of the base branch `name` in the mirror. + fn base_ref(name: &str) -> String { + format!("refs/remotes/origin/{name}") + } + + /// The full ref of the compare branch `name` in the mirror. + fn compare_ref(&self, name: &str) -> String { + format!("refs/fork/{}/{}", self.namespace, name) + } +} + +/// The refs namespace of a fork's import in the target mirror. +fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + sanitize_path_component(&announcement.id) + ) +} + +/// The announced forks of `base` a New PR compare can be built from: +/// announcements related by `u` tag or shared EUC, excluding the base +/// itself and announcements without `clone` URLs (unfetchable). Own forks +/// (announced by `user`) come first; the input order (newest first, as +/// `RepoListStore` keeps it) is preserved within each group. +fn fork_candidates<'a>( + announcements: &'a [Announcement], + base: &RepoAddr, + base_euc: Option<&str>, + user: Option, +) -> Vec<&'a Announcement> { + let (mut own, mut others) = (Vec::new(), Vec::new()); + for announcement in announcements { + if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { + continue; + } + if Some(announcement.owner) == user { + own.push(announcement); + } else { + others.push(announcement); + } + } + own.into_iter().chain(others).collect() +} + +/// The display name of an announcement: its human-readable name, falling +/// back to the repository id. +fn fork_display_name(announcement: &Announcement) -> SharedString { + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) +} + +/// A short label of a fork's owner for the source picker (hex prefix). +fn shorten_owner(owner: &PublicKey) -> String { + let hex = owner.to_hex(); + hex.chars().take(10).collect() +} + +/// Truncate a label for the fixed-width controls of the compare bar. +fn truncate_label(label: &str) -> SharedString { + const MAX: usize = 18; + let mut chars = label.chars(); + let (prefix, rest) = (chars.by_ref().take(MAX).collect::(), chars.next()); + let label = if rest.is_some() { + format!("{}…", &prefix[..prefix.len().saturating_sub(1)]) + } else { + prefix + }; + SharedString::from(label) +} + +/// The compare-source menu entry of one local checkout folder: applies the +/// folder directly (no picker). +fn checkout_source_item( + view: WeakEntity, + path: PathBuf, + active: bool, +) -> PopupMenuItem { + let subtitle = path.display().to_string(); + let title = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| subtitle.clone()); + PopupMenuItem::element(move |_window, cx| { + source_row( + IconName::Folder, + truncate_label(&title), + truncate_label(&subtitle), + cx, + ) + }) + .checked(active) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.apply_folder_path(path.clone(), window, cx) + }); + } + }) +} + +/// The compare-source menu entry prompting for an arbitrary folder. +fn choose_folder_source_item(view: WeakEntity) -> PopupMenuItem { + PopupMenuItem::element(move |_window, cx| { + source_row( + IconName::FolderOpen, + "Choose another folder…", + "Pick any local checkout", + cx, + ) + }) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.choose_checkout(window, cx)); + } + }) +} + +/// The compare-source menu entry of one announced fork: +/// imports its branches into the target's mirror and switches the panel to fork mode. +fn fork_source_item( + view: WeakEntity, + announcement: Announcement, + subtitle: SharedString, + _active: bool, +) -> PopupMenuItem { + let title = truncate_label(&fork_display_name(&announcement)); + + PopupMenuItem::element(move |_window, cx| { + source_row( + CustomIconName::GitBranch, + title.clone(), + truncate_label(&subtitle), + cx, + ) + }) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.choose_fork(announcement.clone(), window, cx) + }); + } + }) +} + +/// One row of the compare-source menu: icon, title and a muted subtitle. +fn source_row(icon: impl Into, title: T, subtitle: T, cx: &App) -> AnyElement +where + T: Into, +{ + let title = title.into(); + let subtitle = subtitle.into(); + + h_flex() + .gap_2() + .w_full() + .min_w_0() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + v_flex() + .min_w_0() + .flex_1() + .child( + div() + .w_full() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_sm() + .line_height(relative(1.25)) + .child(title), + ) + .child( + div() + .w_full() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_height(relative(1.25)) + .child(subtitle), + ), + ) + .into_any_element() +} + impl NewPullRequestView { pub fn new( dock_area: WeakEntity, @@ -149,13 +367,14 @@ impl NewPullRequestView { ), ]; - Self { + let mut view = Self { focus_handle: cx.focus_handle(), dock_area, store, repo_name, repo_path: None, branches: Vec::new(), + fork: None, base: SharedString::default(), compare: SharedString::default(), base_select, @@ -174,6 +393,54 @@ impl NewPullRequestView { item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, tasks: Vec::new(), + }; + + // Prefill: when the store knows an associated checkout of this repository, + // apply the freshest one right away (no folder dialog). + let addr = view.store.read(cx).addr().clone(); + if let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + { + view.apply_folder_path(path, window, cx); + } + + view + } + + /// Whether a compare source (a checkout or a fork) is applied. + fn has_source(&self) -> bool { + self.repo_path.is_some() || self.fork.is_some() + } + + /// The path git ops run against: the target's mirror in fork mode, the + /// user's checkout otherwise. + fn work_path(&self) -> Option { + match &self.fork { + Some(fork) => Some(fork.mirror_path.clone()), + None => self.repo_path.clone(), + } + } + + /// The full ref the selected base branch resolves to: the mirror's + /// remote-tracking ref in fork mode, the plain branch name in checkout + /// mode (where git resolves it through `refs/heads`). + fn base_ref(&self) -> String { + match &self.fork { + Some(_) => ForkCompare::base_ref(&self.base), + None => self.base.to_string(), + } + } + + /// The full ref the selected compare branch resolves to: the imported + /// `refs/fork/` ref in fork mode, the plain branch name in + /// checkout mode. + fn compare_ref(&self) -> String { + match &self.fork { + Some(fork) => fork.compare_ref(&self.compare), + None => self.compare.to_string(), } } @@ -198,8 +465,22 @@ impl NewPullRequestView { let Some(path) = picked else { return Ok(()); }; - let path = path.to_string_lossy().to_string(); + this.update_in(cx, |this, window, cx| { + this.apply_folder_path(path, window, cx); + })?; + + Ok(()) + }); + self.tasks.push(task); + } + + /// Apply `path` as the local checkout (no picker): read its branches + /// and current branch off the UI thread, then apply. + fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + let path = path.to_string_lossy().to_string(); + + let task = cx.spawn_in(window, async move |this, cx| { // Branches and the current branch are read off the UI thread. let info = cx .background_spawn({ @@ -224,6 +505,8 @@ impl NewPullRequestView { } /// Apply a picked checkout: fill the selectors and load the compare. + /// Leaves fork mode; a fork applied earlier keeps its import in the + /// mirror (harmless) but the panel switches back to the checkout. fn apply_checkout( &mut self, path: String, @@ -231,6 +514,7 @@ impl NewPullRequestView { window: &mut Window, cx: &mut Context, ) { + self.fork = None; let Some((branches, current)) = info else { self.error = Some("The chosen folder is not a git repository".into()); self.repo_path = None; @@ -267,6 +551,13 @@ impl NewPullRequestView { self.error = None; self.branches = branches.into_iter().map(SharedString::from).collect(); + // Learning: remember this folder as a checkout of the target + // repository, so the next panel pre-fills it. + let addr = self.store.read(cx).addr().clone(); + CheckoutsStore::global(cx).update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + let branches = self.branches.clone(); let base = SharedString::from(base.clone()); let compare = SharedString::from(compare.clone()); @@ -284,14 +575,262 @@ impl NewPullRequestView { self.reload_compare(window, cx); } + /// The base repository of the panel: its address and announced EUC, + /// used to find fork candidates. + fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { + let store = self.store.read(cx); + let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); + (store.addr().clone(), euc) + } + + /// Announced forks of the target repository the compare can be built + /// from (own forks first), re-read whenever the picker opens. + fn fork_candidates(&self, cx: &App) -> Vec { + let (base, euc) = self.base_repo(cx); + let user = Backend::global(cx).read(cx).current_user(); + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); + fork_candidates(&announcements, &base, euc.as_deref(), user) + .into_iter() + .cloned() + .collect() + } + + /// Compare against an announced fork: ensure the target's GitCache + /// mirror, import the fork's heads under `refs/fork/…`, then fill the + /// selectors (base from `refs/remotes/origin/*`, compare from the + /// import) and load the compare. Picking the fork already applied + /// refreshes it instead (re-import + reload), keeping the branch selection. + fn choose_fork( + &mut self, + announcement: Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let refresh = self + .fork + .as_ref() + .is_some_and(|fork| fork.announcement.addr() == announcement.addr()); + + let (base, _euc) = self.base_repo(cx); + let cache = GitStore::global(cx).cache().clone(); + let mirror_path = cache.repo_path(&base); + let namespace = fork_namespace(&announcement); + let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); + + let base_clone_urls: Vec = self + .store + .read(cx) + .announcement + .as_ref() + .map(|a| a.clone.iter().map(ToString::to_string).collect()) + .unwrap_or_default(); + + // The default compare branch of the fork, if the refreshed fork is + // the one applied and its branch still exists. + let keep_compare = refresh.then(|| self.compare.clone()); + let keep_base = refresh.then(|| self.base.clone()); + + // The fork applied when the fetch started; if the user switches the + // source mid-flight, the result must not clobber the newer state. + let expected_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); + + self.loading = true; + self.error = None; + cx.notify(); + + let task = cx.spawn_in(window, async move |this, cx| { + // The fork and the base must share history for a merge-base to exist, + // so the target's mirror is the object store both sides land in. + // `ensure_clone` fetches `origin` when the mirror exists already. + let result = cx + .background_spawn({ + let cache = cache.clone(); + let base = base.clone(); + let base_clone_urls = base_clone_urls.clone(); + let namespace = namespace.clone(); + let clone_urls = clone_urls.clone(); + let mirror_path = mirror_path.clone(); + async move { + // The fork and the base must share history for a + // merge-base to exist, so the target's mirror is the + // object store both sides land in. `ensure_clone` + // fetches `origin` when the mirror exists already. + cache.ensure_clone(&base, &base_clone_urls)?; + + // Prune stale imports of any fork, then import this fork's heads under its namespace. + delete_refs_with_prefix(&mirror_path, "refs/fork")?; + + fetch_repo_refs( + &mirror_path, + &clone_urls, + &format!("+refs/heads/*:refs/fork/{namespace}/*"), + )?; + + // Both branch lists are short names, kept sorted like the checkout's. + let strip = |refs: Vec, prefix: &str| { + let mut names: Vec = refs + .into_iter() + .filter_map(|name| { + name.strip_prefix(prefix) + .map(|rest| rest.trim_start_matches('/').to_owned()) + }) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names + }; + + let base_branches = strip( + refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + "refs/remotes/origin", + ); + + let compare_branches = strip( + refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + &format!("refs/fork/{namespace}"), + ); + + Ok::<_, anyhow::Error>((base_branches, compare_branches)) + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + // A source switch mid-flight (e.g. the user picked a folder while the fork was fetching) discards the stale result. + let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); + if applied != expected_fork { + this.loading = false; + cx.notify(); + return; + } + + this.apply_fork( + announcement, + mirror_path, + namespace, + result, + keep_base, + keep_compare, + window, + cx, + ); + })?; + + Ok(()) + }); + self.tasks.push(task); + } + + /// Apply an imported fork: fill the selectors and load the compare. + #[allow(clippy::too_many_arguments)] + fn apply_fork( + &mut self, + announcement: Announcement, + mirror_path: PathBuf, + namespace: String, + result: Result<(Vec, Vec), anyhow::Error>, + keep_base: Option, + keep_compare: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.loading = false; + let (base_branches, compare_branches) = match result { + Ok(branches) => branches, + Err(error) => { + // Keep the previous source (if any); the error is shown + // inline next to the compare bar. + self.error = Some(format!("Could not compare against the fork: {error}").into()); + cx.notify(); + return; + } + }; + if compare_branches.is_empty() { + self.error = Some("The fork has no branches to compare".into()); + cx.notify(); + return; + } + if base_branches.is_empty() { + self.error = + Some("Could not list the target repository's branches; try again later".into()); + cx.notify(); + return; + } + + let base_branches: Vec = + base_branches.into_iter().map(SharedString::from).collect(); + let compare_branches: Vec = compare_branches + .into_iter() + .map(SharedString::from) + .collect(); + + // Defaults: the announced HEAD branch when the mirror has it + // (falling back to `main`, then the first branch); the fork's + // `main` for the compare side (falling back to the first branch). + // A refresh keeps the previous selection when the branch still + // exists. + let announced = self.store.read(cx).head.clone(); + let contains = + |name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name); + let keep_base = keep_base.filter(|name| contains(name, &base_branches)); + let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches)); + let base = keep_base + .or_else(|| { + announced + .as_ref() + .filter(|branch| contains(branch, &base_branches)) + .map(SharedString::from) + }) + .or_else(|| { + base_branches + .iter() + .find(|branch| branch.as_ref() == "main") + .cloned() + }) + .unwrap_or_else(|| base_branches[0].clone()); + let compare = keep_compare + .or_else(|| { + compare_branches + .iter() + .find(|branch| branch.as_ref() == "main") + .cloned() + }) + .unwrap_or_else(|| compare_branches[0].clone()); + + self.fork = Some(ForkCompare { + announcement, + namespace, + mirror_path, + }); + self.error = None; + self.base = base.clone(); + self.compare = compare.clone(); + self.base_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(base_branches), window, cx); + state.set_selected_values(&[base], window, cx); + }); + self.compare_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(compare_branches), window, cx); + state.set_selected_values(&[compare], window, cx); + }); + + self.reload_compare(window, cx); + } + /// (Re)compute `merge_base..compare` of the selected branches on a - /// background task: the merge base, the commit list and the diff. + /// background task: the merge base, the commit list and the diff. Runs + /// against the work path (the checkout, or the mirror in fork mode) + /// using the full refs of both branches, so base `main` and fork `main` + /// stay distinct. fn reload_compare(&mut self, window: &mut Window, cx: &mut Context) { - let Some(repo_path) = self.repo_path.clone() else { + let Some(repo_path) = self.work_path() else { return; }; - let base = self.base.to_string(); - let compare = self.compare.to_string(); + let base = self.base_ref(); + let compare = self.compare_ref(); + // Short names for the error copy; the full refs go to git. + let base_name = self.base.to_string(); + let compare_name = self.compare.to_string(); self.loading = true; self.error = None; @@ -315,10 +854,14 @@ impl NewPullRequestView { let repo_path = repo_path.clone(); let base = base.clone(); let compare = compare.clone(); + let base_name = base_name.clone(); + let compare_name = compare_name.clone(); async move { let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? .ok_or_else(|| { - anyhow::anyhow!("{base} and {compare} share no common ancestor") + anyhow::anyhow!( + "{base_name} and {compare_name} share no common ancestor" + ) })?; let commits = worktree_commit_range_commits( Path::new(&repo_path), @@ -372,15 +915,24 @@ impl NewPullRequestView { if self.submitting || self.loading { return; } - let Some(repo_path) = self.repo_path.clone() else { - return; - }; + let Some(merge_base) = self.merge_base.clone() else { return; }; + + let Some(repo_path) = self.work_path() else { + return; + }; + let subject = self.subject.read(cx).value().to_string(); let description = self.description.read(cx).value().to_string(); + + // The published `branch-name` is the compare branch's short name. let branch_name = self.compare.to_string(); + + // The patch is generated from the compare ref: a plain branch name + // in checkout mode, the imported `refs/fork/…` ref in fork mode. + let compare_ref = self.compare_ref(); let store = self.store.clone(); let dock_area = self.dock_area.clone(); let entity = cx.entity().clone(); @@ -390,15 +942,14 @@ impl NewPullRequestView { cx.notify(); let task = cx.spawn_in(window, async move |this, cx| { - // Regenerate the series at submit time so the published patch - // covers the current tip of the compare branch. + // Regenerate the series at submit time so the published patch covers the current tip of the compare branch. let patch = cx .background_spawn({ let repo_path = repo_path.clone(); let merge_base = merge_base.clone(); - let branch_name = branch_name.clone(); + let compare_ref = compare_ref.clone(); async move { - format_patch_between(Path::new(&repo_path), &merge_base, &branch_name) + format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref) } }) .await; @@ -454,12 +1005,13 @@ impl NewPullRequestView { Ok(()) }); + self.tasks.push(task); } /// Open the diff of `commit_id` (from the Commits tab) in a new panel. fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { - let Some(repo_path) = self.repo_path.clone() else { + let Some(repo_path) = self.work_path() else { return; }; let Some(dock_area) = self.dock_area.upgrade() else { @@ -481,12 +1033,11 @@ impl NewPullRequestView { }); } - /// The compare bar: base/compare selectors, the checkout chooser and the - /// Create button. + /// The compare bar: base/compare selectors, the source picker (local + /// checkout / announced fork) and the Create button. fn render_compare_bar(&self, cx: &mut Context) -> AnyElement { - let has_checkout = self.repo_path.is_some(); - let checkout = self.repo_path.clone(); - let can_submit = has_checkout + let has_source = self.has_source(); + let can_submit = has_source && !self.loading && !self.submitting && self.merge_base.is_some() @@ -496,6 +1047,25 @@ impl NewPullRequestView { .is_some_and(|commits| !commits.is_empty()) && !self.subject.read(cx).value().is_empty(); + // Source-picker data, snapshotted when the menu is built + // (each open rebuilds the items from the live announcements). + let source_menu = self.source_menu(cx); + let source_label = self.source_trigger(); + let source_tooltip = match &self.fork { + Some(fork) => { + format!( + "Comparing against {}", + fork_display_name(&fork.announcement) + ) + } + None => self.repo_path.as_ref().map_or_else( + || "Choose a compare source".into(), + |p| p.display().to_string(), + ), + }; + + let refresh_fork = self.fork.as_ref().map(|fork| fork.announcement.clone()); + h_flex() .px_4() .h_16() @@ -518,7 +1088,7 @@ impl NewPullRequestView { .placeholder("branch") .appearance(false) .menu_width(px(220.)) - .disabled(!has_checkout) + .disabled(!has_source) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { @@ -543,7 +1113,7 @@ impl NewPullRequestView { .placeholder("branch") .appearance(false) .menu_width(px(220.)) - .disabled(!has_checkout) + .disabled(!has_source) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { @@ -553,17 +1123,38 @@ impl NewPullRequestView { ), ) .child( - Button::new("choose-checkout") - .icon(IconName::Folder) - .ghost() - .tooltip(checkout.as_ref().map_or_else( - || "Choose a local checkout".into(), - |path| path.display().to_string(), - )) - .on_click(cx.listener(|this, _event, window, cx| { - this.choose_checkout(window, cx); - })), + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Source"), + ) + .child( + Button::new("compare-source") + .ghost() + .w(px(190.)) + .child(div().text_sm().child(source_label)) + .dropdown_caret(true) + .tooltip(source_tooltip) + .dropdown_menu(source_menu), + ), ) + .when_some(refresh_fork, |this, fork| { + this.child( + v_flex().gap_1().child(div()).child( + Button::new("refresh-fork") + .icon(CustomIconName::Refresh) + .ghost() + .tooltip("Re-fetch the fork") + .on_click(cx.listener(move |this, _event, window, cx| { + this.choose_fork(fork.clone(), window, cx); + })), + ), + ) + }) .child(div().flex_1()) .child( Button::new("create-pr") @@ -578,6 +1169,74 @@ impl NewPullRequestView { .into_any_element() } + /// The source picker's trigger: truncated label of the applied source. + fn source_trigger(&self) -> SharedString { + match &self.fork { + Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), + None => self.repo_path.as_ref().map_or_else( + || SharedString::from("No source"), + |path| truncate_label(&path.display().to_string()), + ), + } + } + + /// Build the compare-source menu: switching back to the local checkout, + /// then the announced forks of the target repository (own forks first). + /// Picking the fork already applied re-fetches it. Rebuilt every time + /// the menu opens, so the candidates are always current. + fn source_menu( + &self, + cx: &Context, + ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { + let view = cx.entity().downgrade(); + // Associated local checkouts of the target repository, freshest + // first; the applied one is checked. The picker prompt stays + // available underneath for arbitrary folders. + let addr = self.store.read(cx).addr().clone(); + let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + let active_path = (self.fork.is_none()) + .then(|| self.repo_path.clone()) + .flatten(); + let candidates = self.fork_candidates(cx); + let user = Backend::global(cx).read(cx).current_user(); + let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); + + move |mut menu, _window, _cx| { + for path in &associated { + menu = menu.item(checkout_source_item( + view.clone(), + path.clone(), + active_path.as_ref() == Some(path), + )); + } + + menu = menu.item(choose_folder_source_item(view.clone())); + menu = menu.item(PopupMenuItem::separator()); + + if candidates.is_empty() { + menu = menu.item(PopupMenuItem::label( + "No announced forks of this repository", + )); + } else { + for candidate in candidates.iter() { + let subtitle: SharedString = if Some(candidate.owner) == user { + "Your fork".into() + } else { + SharedString::from(format!("by {}", shorten_owner(&candidate.owner))) + }; + menu = menu.item(fork_source_item( + view.clone(), + candidate.clone(), + subtitle, + active_fork == Some(candidate.addr()), + )); + } + } + + menu + } + } + /// The title and description inputs. fn render_inputs(&self, _cx: &mut Context) -> AnyElement { v_flex() @@ -671,8 +1330,11 @@ impl NewPullRequestView { .child(Spinner::new().small()) .into_any_element(); } - if self.repo_path.is_none() { - return placeholder("Choose a local checkout to compare branches", cx); + if !self.has_source() { + return placeholder( + "Choose a local checkout or an announced fork to compare", + cx, + ); } if self.commits.is_none() && self.error.is_some() { return placeholder("Nothing to compare", cx); @@ -865,3 +1527,140 @@ impl Render for NewPullRequestView { ) } } + +#[cfg(test)] +mod tests { + use nostr::prelude::*; + use signed_core::repo_addr; + + use super::*; + + const OWNER_KEYS: [&str; 3] = [ + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000003", + ]; + + /// Build a signed kind-30617 event for `owner` with the given tags. + fn announcement_event(owner: &str, tags: &[&[&str]]) -> Event { + let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys) + .expect("signed event") + } + + fn announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec { + vec![ + Announcement::from_event(&announcement_event(OWNER_KEYS[owner_ix], tags)) + .expect("parses"), + ] + } + + #[test] + fn fork_candidates_orders_own_forks_first() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let clone = "https://grasp.example/npub1x/my-fork.git"; + + let base_addr = repo_addr( + PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), + "upstream", + ); + // Newest first, as RepoListStore keeps them: an unrelated repo, the + // user's own fork (shared EUC), someone else's fork (u tag). + let all = vec![ + announcements( + 2, + &[ + &["d", "other-project"], + &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + announcements( + 1, + &[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]], + ) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "their-fork"], + &["u", &base_addr.to_string()], + &["clone", clone], + ], + ) + .pop() + .unwrap(), + ]; + + let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + + // The user's fork comes first, then the other author's. + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["my-fork", "their-fork"]); + } + + #[test] + fn fork_candidates_excludes_base_unrelated_and_unfetchable() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); + let base_addr = repo_addr(base_owner, "upstream"); + + let mut all = vec![ + announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "other"], + &["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "mirror"], + &["r", euc, "euc"], + &["clone", "https://grasp.example/x/mirror.git"], + ], + ) + .pop() + .unwrap(), + ]; + + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + assert_eq!(forks.len(), 1); + assert_eq!(forks[0].id, "mirror"); + + // Without a base EUC only `u`-tag forks match. + all.push( + announcements( + 2, + &[ + &["d", "u-fork"], + &["u", &base_addr.to_string()], + &["clone", "https://grasp.example/x/u-fork.git"], + ], + ) + .pop() + .unwrap(), + ); + let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["u-fork"]); + } +} diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index e7e42ce..a2e4b1e 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -92,10 +92,11 @@ impl PullRequestsView { pub fn new( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, _window: &mut Window, cx: &mut Context, ) -> Self { + let repo_name = store.read(cx).name(); + Self { focus_handle: cx.focus_handle(), dock_area, diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..bfd7d76 --- /dev/null +++ b/docs/PLAN.md @@ -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//.git` and advertised in the PR's + `clone` tag, so contributing to someone else's project never depends on their + servers accepting anything from you. +- **C. Checkout associations & suggestions** — remember/derive which local + folders are checkouts of which announced repos, auto-prefill the New PR panel + (no folder picker for the common case), and suggest creating a PR when a + branch is ahead with no open PR (GitHub-like nudge, NIP-34-native dedupe). + +Guiding principles (agreed): follow nostr + grasp + ngit, not GitHub; +NIP-34/GRASP-06 event surface stays untouched (no new tags/kinds, forks never +appear in events); every flow keeps the patch series as the source of truth; +no over-engineering — reuse existing stores, patterns and git helpers. + +--- + +## 1. Protocol grounding (what we may and may not do) + +### 1.1 NIP-34 facts used by this plan + +- A PR (kind-1618) is addressed to the **base** repo coordinate (`a` tag) and + carries `c` (tip), `merge-base` (common ancestor with the target branch), + `branch-name`, `clone` (≥1 URL where the tip commit can be downloaded), + `e` → root patch event, `r` (EUC), `p` (base owner). Patches are + NIP-10-chained kind-1617 events, ≤60 KB each. Statuses 1630–1633 resolve the + PR. +- Repository announcements (30617): `u` tag marks a subordinate fork + (`30617::` 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):///prs//.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/` 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/` 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 '+refs/heads/*:refs/fork///*' +``` + +- `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///*` + 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/", "refs/fork/…/")`, 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/` 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/`, 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///* + 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/ (author servers, first) + U->>B: push tip → refs/nostr/ (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 `, 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>` — + 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>` (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`: 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//.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`: 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` (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` (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. diff --git a/docs/PR_FLOW.md b/docs/PR_FLOW.md index 1ab69f2..c1066b8 100644 --- a/docs/PR_FLOW.md +++ b/docs/PR_FLOW.md @@ -11,23 +11,22 @@ state. ```mermaid graph TD - A["New pull request dialog"] --> B{"Patch source"} - B -->|"Paste"| C["Paste git format-patch output"] - B -->|"Local checkout"| D["Browse for checkout"] - D --> E["Defaults: source = current branch, target = announced HEAD"] - E --> F["Generate: merge-base plus format-patch base..tip"] - F --> G["Apply check vs mirror clone - non-blocking warning"] - C --> H["Submit"] - F --> H - G --> H + A["New pull request panel"] --> B{"Compare source"} + B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"] + B -->|"Announced fork"| D["Pick fork repo + branch"] + D --> D1["Ensure base mirror (GitCache), fetch origin"] + D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"] + C --> E["Defaults: target = announced HEAD, source = current branch / fork main"] + E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"] + F --> H["Submit: format-patch base..tip at publish time"] H --> I["split_patch_series: one part per commit"] I --> J{"Any part over 60 KB?"} J -->|"Yes"| K["Refuse with message"] 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"] - 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"] - 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 --> R["Publish kind-1618 PR event"] Q --> R @@ -50,47 +49,81 @@ graph TD Key points of the write side: -- **Merge base**: only computable in the local-checkout path - (`signed_git::merge_base`); the paste path publishes none. The dialog - reuses it at submit only while the patch textarea is unchanged. +- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the + wire): + - *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///*` (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/` + (nak's convention) to the *author's* grasp servers first — + `https:///prs//.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 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`/`r` tags. -- **Push before publish**: the tip is pushed to every announced grasp - server under `refs/nostr/` (nak's convention) so the announced - `clone` URLs really can serve the commit. Failure is non-fatal — the - patch events remain the source of truth — and surfaces as a - `last_warning` banner. +- **Push before publish**: failure is non-fatal — the patch events remain + the source of truth — and surfaces as a `last_warning` banner. +- **1619 updates are paste-only today** (no repo path holds the new tip's + objects), so updates are not pushed; hosting them is deferred until the + update dialog gains a local-checkout source. ## Creating a pull request - event ordering ```mermaid sequenceDiagram participant User - participant App - participant Checkout as Local checkout - participant Grasp as Grasp servers - participant Relays as Nostr relays + 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 - User->>App: pick checkout and branches, Generate - App->>Checkout: merge-base(source, target) - Checkout-->>App: base commit - App->>Checkout: format-patch base..tip - Checkout-->>App: patch series - App->>App: split series, check per-part size + User->>P: ensure mirror (fork mode) / pick local checkout + P-->>F: fetch fork heads -> refs/fork/... (fork mode) + User->>P: merge-base, range commits, range diff + User->>P: submit: format-patch base..compare-ref 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 - App->>App: build and sign kind-1618 PR event - App->>Grasp: push tip to refs/nostr/event-id - Grasp-->>App: accepted or rejected (best-effort) - App->>Relays: publish kind-1618 PR event + User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced) + User->>A: push tip to refs/nostr/event-id (author servers, first) + User->>B: push tip to refs/nostr/event-id (best-effort) + A-->>User: accepted or rejected (all rejected -> warning) + User->>R: publish kind-1618 PR event opt draft - App->>Relays: publish kind-1633 draft status + User->>R: publish kind-1633 draft status 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 ```mermaid @@ -100,7 +133,7 @@ sequenceDiagram participant Maintainer 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 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. - **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from 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. diff --git a/docs/TODO.md b/docs/TODO.md index 05e68b9..939343d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -33,8 +33,10 @@ - [ ] GRASP-06 `/prs//.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. -- [ ] Local-checkout generation for the update-PR dialog (currently paste-only). -- [ ] 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. +- [ ] 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. +- [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///*`; `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//.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