diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8b1f6..3cec89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ ### Changed +- Surface backend errors as error notifications in the workspace instead of silently dropping them +- Break ties in repository activity lists by event id, so same-second events order deterministically +- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils` + ### Fixed ### Removed +- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths + ### Deprecated ## v0.2.0-alpha - 2026/09/27 diff --git a/Cargo.lock b/Cargo.lock index 6a182e8..b025955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,12 +881,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "btreecap" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" - [[package]] name = "built" version = "0.8.1" @@ -5741,17 +5735,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nostr-memory" -version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336" -dependencies = [ - "btreecap", - "nostr", - "nostr-database", - "tokio", -] - [[package]] name = "nostr-sdk" version = "0.45.4" @@ -8151,6 +8134,7 @@ version = "0.2.0-alpha" dependencies = [ "nostr", "serde", + "utils", ] [[package]] @@ -8178,7 +8162,6 @@ dependencies = [ "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", - "nostr-memory", "nostr-sdk", "webbrowser", ] diff --git a/Cargo.toml b/Cargo.toml index 0a21411..d4287b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ settings = { path = "crates/settings" } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } -nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index c5b3da6..b3f82f4 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); /// On Windows, this is `%APPDATA%\Signed`. static CONFIG_DIR: OnceLock = OnceLock::new(); -pub fn home_dir() -> PathBuf { +fn home_dir() -> PathBuf { dirs::home_dir().expect("failed to determine home directory") } @@ -41,7 +41,7 @@ pub fn documents_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -pub fn config_dir() -> &'static PathBuf { +fn config_dir() -> &'static PathBuf { CONFIG_DIR.get_or_init(|| { if cfg!(target_os = "windows") { dirs::config_dir() @@ -60,7 +60,7 @@ pub fn config_dir() -> &'static PathBuf { }) } -pub fn data_dir() -> &'static PathBuf { +fn data_dir() -> &'static PathBuf { CURRENT_DATA_DIR.get_or_init(|| { if cfg!(target_os = "macos") { home_dir() diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index f4d90a4..a6da80d 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -7,3 +7,4 @@ publish.workspace = true [dependencies] nostr.workspace = true serde.workspace = true +utils = { path = "../utils" } diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 79d57fc..a3d0194 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,23 +1,99 @@ +use std::fmt; +use std::str::FromStr; + use nostr::prelude::*; +use serde::{Deserialize, Serialize}; -/// Address of a NIP-34 repository announcement, `30617::`. -/// -/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this, -/// the alias reuses the SDK type while keeping repository-specific vocabulary. -pub type RepoAddr = Coordinate; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RepoAddr(Coordinate); -pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { - Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) +impl RepoAddr { + pub fn new(owner: PublicKey, identifier: impl Into) -> Self { + Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier)) + } + + pub fn identifier_from_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '/' { + c + } else { + '-' + } + }) + .collect() + } + + pub fn kind(&self) -> Kind { + self.0.kind + } + + pub fn public_key(&self) -> PublicKey { + self.0.public_key + } + + pub fn identifier(&self) -> &str { + &self.0.identifier + } + + pub fn coordinate(&self) -> &Coordinate { + &self.0 + } + + pub fn announcement_filter(&self) -> Filter { + Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(self.public_key()) + .identifier(self.identifier()) + } + + pub fn state_filter(&self) -> Filter { + Filter::new() + .kind(Kind::RepoState) + .author(self.public_key()) + .identifier(self.identifier()) + } + + // Statuses may omit the `a` tag per NIP-34; those are not matched here. + pub fn activity_filter(&self) -> Filter { + Filter::new() + .kinds(crate::filters::ACTIVITY_KINDS) + .coordinate(&self.0) + } + + pub fn deletion_filters(&self) -> Vec { + vec![ + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .author(self.public_key()), + Filter::new().kind(Kind::EventDeletion).coordinate(&self.0), + ] + } } -pub fn identifier_from_name(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '/' { - c - } else { - '-' - } - }) - .collect() +impl fmt::Display for RepoAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl FromStr for RepoAddr { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Coordinate::from_str(s).map(RepoAddr) + } +} + +impl From for RepoAddr { + fn from(coordinate: Coordinate) -> Self { + RepoAddr(coordinate) + } +} + +impl From for Coordinate { + fn from(addr: RepoAddr) -> Self { + addr.0 + } } diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index 1fbcd66..7fca784 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -2,25 +2,14 @@ use std::collections::HashSet; use nostr::prelude::*; -/// NIP-09 deletion requests and NIP-62 vanish requests, -/// built from the kind-5 and kind-62 events in the local database. -/// -/// Deleted events are hidden before they reach the UI. -/// -/// Pass any event through [`Deletions::is_deleted`] before showing it. pub struct Deletions { - /// `(deleted event id, expected author)` from `e` tags of kind-5 events. ids: HashSet<(EventId, PublicKey)>, - /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. - /// - /// All versions of the addressable event up to `cutoff` are deleted. + // All versions of the addressable event up to `cutoff` are deleted. coords: Vec<(Coordinate, PublicKey, Timestamp)>, - /// `(author, cutoff)` from kind-62 vanish requests. vanished: Vec<(PublicKey, Timestamp)>, } impl Deletions { - /// Build the deletion index from raw kind-5 and kind-62 events. pub fn from_events(events: impl IntoIterator) -> Self { let mut ids = HashSet::new(); let mut coords = Vec::new(); @@ -36,8 +25,8 @@ impl Deletions { .map(|c| (c, event.pubkey, event.created_at)), ); } else if event.kind == Kind::RequestToVanish { - // Client-side we can't verify which relay the request targeted. - // Any vanish request is then honored for the author's events. + // We can't verify which relay the request targeted, so any + // vanish request is honored for the author's events. vanished.push((event.pubkey, event.created_at)); } } @@ -49,10 +38,8 @@ impl Deletions { } } - /// Whether the event is covered by a valid deletion or vanish request. - /// A request is valid when its author matches the deleted event's author, per NIP-09. - /// - /// Addressable events are deleted up to the request's `created_at`. + // A request is valid when its author matches the deleted event's author, + // per NIP-09. Addressable events are deleted up to the request's `created_at`. pub fn is_deleted(&self, event: &Event) -> bool { if self .vanished diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index c8180a0..9b09a6a 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -2,9 +2,6 @@ use std::time::Duration; use nostr::prelude::*; -use crate::RepoAddr; - -/// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::Comment, Kind::GitPatch, @@ -17,8 +14,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::GitStatusDraft, ]; -/// Kinds that notify a user when they tag them via their `p` tag. -pub const NOTIFICATION_KINDS: [Kind; 8] = [ +const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch, @@ -29,7 +25,6 @@ pub const NOTIFICATION_KINDS: [Kind; 8] = [ Kind::GitStatusDraft, ]; -/// Git root kinds that make a comment count as git activity. const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitIssue, Kind::GitPatch, @@ -37,7 +32,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitRepoAnnouncement, ]; -/// Kinds that carry repository data: announcements, states, activity and deletions. pub fn is_repo_kind(kind: Kind) -> bool { kind == Kind::GitRepoAnnouncement || kind == Kind::RepoState @@ -46,7 +40,6 @@ pub fn is_repo_kind(kind: Kind) -> bool { || ACTIVITY_KINDS.contains(&kind) } -/// Value of the first tag named `name` on `event`. fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { event .tags @@ -55,175 +48,104 @@ fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { .and_then(|tag| tag.content()) } -/// Kind named by the first tag `name` on `event`. fn tag_kind(event: &Event, name: &str) -> Option { tag_value(event, name)?.parse::().ok() } -/// Latest announcement event for a repository. -pub fn announcement(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +pub struct Filters; -/// Latest state event for a repository, carrying refs and HEAD. -pub fn state(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::RepoState) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +impl Filters { + const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); -/// All NIP-34 activity addressed to a repository via its `#a` tag. -/// Covers issues, PRs, patches, statuses and kind-1111 comments. -/// The `a` tag is optional on status events per NIP-34. -/// Statuses published without it are not matched here. -pub fn activity(addr: &RepoAddr) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) -} - -/// Status events, kinds `1630..=1633`, referencing any of the given root events. -/// They are matched via the `#e` tag. One filter covers all roots. -/// -/// A negentropy sync reconciles them in a single session, not one per root. -pub fn statuses_for(roots: impl IntoIterator) -> Filter { - Filter::new() - .kinds([ - Kind::GitStatusOpen, - Kind::GitStatusApplied, - Kind::GitStatusClosed, - Kind::GitStatusDraft, - ]) - .events(roots) -} - -/// A user's grasp list, kind `10317`. -pub fn grasp_list(public_key: PublicKey) -> Filter { - Filter::new() - .kind(Kind::GitUserGraspList) - .author(public_key) -} - -/// NIP-22 comments, kind `1111`, referencing any of the given root events. -/// The roots are issues, patches and PRs. -/// -/// Returns two filters, since combining `#E` and `#e` would AND the conditions. -pub fn comments_for(roots: impl IntoIterator) -> Vec { - let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); - if roots.is_empty() { - return Vec::new(); + pub fn statuses_for(roots: impl IntoIterator) -> Filter { + Filter::new() + .kinds([ + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, + ]) + .events(roots) } - vec![ + + pub fn grasp_list(public_key: PublicKey) -> Filter { + Filter::new() + .kind(Kind::GitUserGraspList) + .author(public_key) + } + + // Two filters: combining `#E` and `#e` would AND the conditions. + pub fn comments_for(roots: impl IntoIterator) -> Vec { + let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); + if roots.is_empty() { + return Vec::new(); + } + vec![ + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::LOWERCASE_E, roots), + ] + } + + fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) + .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) + } + + // `Filter::pubkey` matches the git events' lowercase `p` tag. + pub fn notifications(me: PublicKey) -> Vec { + vec![ + Self::notification_comments(me), + Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), + ] + } + + // A comment on an unrelated kind matches too, so results must be filtered + // through `GitEvent::is_git_activity` before display. + pub fn authored_activity(me: PublicKey) -> Filter { + Filter::new().kinds(ACTIVITY_KINDS).author(me) + } + + pub fn all_announcements() -> Filter { + Filter::new().kind(Kind::GitRepoAnnouncement) + } + + pub fn all_states() -> Filter { + Filter::new().kind(Kind::RepoState) + } + + // Quantized to whole days so identical filters hash the same. + fn deletions_since() -> Timestamp { + let now = Timestamp::now().as_secs(); + Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK + } + + // Deletion requests must be known before any other event is shown. + pub fn deletions() -> Filter { Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::LOWERCASE_E, roots), - ] + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .since(Self::deletions_since()) + } } -/// NIP-22 comments on our issues, patches and pull requests. -/// They are matched via the uppercase `P` and `K` tags, not authorship. -pub fn notification_comments(me: PublicKey) -> Filter { - Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) - .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) -} - -/// Activity directed at us: comments on our roots, and git events tagging us -/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. -pub fn notifications(me: PublicKey) -> Vec { - vec![ - notification_comments(me), - Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), - ] -} - -/// Git activity authored by `me`, for "Continue where you left off". -/// -/// A comment on an unrelated kind is matched too, so results must be filtered -/// through [`is_git_activity`] before display. -pub fn authored_activity(me: PublicKey) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).author(me) -} - -/// Whether a kind-1111 comment targets a git root, checked via its `K` tag. -fn is_git_comment(event: &Event) -> bool { +pub(crate) fn is_git_comment(event: &Event) -> bool { event.kind == Kind::Comment && tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } -/// Whether a status event references a git root, checked via its `k` tag. -fn is_git_status(event: &Event) -> bool { +pub(crate) fn is_git_status(event: &Event) -> bool { tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } -/// Whether `event` is git activity worth showing in the activity list. -pub fn is_git_activity(event: &Event) -> bool { - match event.kind { - Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, - Kind::Comment => is_git_comment(event), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => is_git_status(event), - _ => false, - } -} - -/// All repository announcements, for global discovery. -pub fn all_announcements() -> Filter { - Filter::new().kind(Kind::GitRepoAnnouncement) -} - -/// All repository state events, carrying each repository's refs and last push time. -pub fn all_states() -> Filter { - Filter::new().kind(Kind::RepoState) -} - -/// How far back deletion requests are fetched and stored. -const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); - -/// `now` minus [`DELETIONS_LOOKBACK`]. -/// Quantized to whole days so identical filters hash the same. -/// -/// This lets the backend's sync dedup match identical filters. -fn deletions_since() -> Timestamp { - let now = Timestamp::now().as_secs(); - Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK -} - -/// All deletion-related events within [`DELETIONS_LOOKBACK`]. -/// These are NIP-09 kind `5` and NIP-62 kind `62`. -/// -/// Deletion requests must be known before any other event is shown. -pub fn deletions() -> Filter { - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .since(deletions_since()) -} - -/// Deletion events relevant to a single repository. -/// -/// Requests authored by the repository owner. -/// -/// Requests addressed to the repository coordinate via its `#a` tag. -pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { - vec![ - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .author(addr.public_key), - Filter::new().kind(Kind::EventDeletion).coordinate(addr), - ] -} - #[cfg(test)] mod tests { use super::*; + use crate::GitEvent; fn keys(seed: u8) -> Keys { let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); @@ -252,14 +174,10 @@ mod tests { ); let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]); - assert!(is_git_activity(&on_git)); - assert!(is_git_activity(&on_repo)); - assert!(!is_git_activity(&on_note)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::Comment, - Vec::new() - ))); + assert!(on_git.is_git_activity()); + assert!(on_repo.is_git_activity()); + assert!(!on_note.is_git_activity()); + assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity()); } #[test] @@ -275,12 +193,8 @@ mod tests { vec![kind_tag("k", Kind::Metadata)], ); - assert!(is_git_activity(&status)); - assert!(!is_git_activity(&unrelated)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::GitStatusClosed, - Vec::new() - ))); + assert!(status.is_git_activity()); + assert!(!unrelated.is_git_activity()); + assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity()); } } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index c848a29..d3365d7 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -4,41 +4,29 @@ use std::time::Duration; use nostr::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{RepoAddr, activity_subject}; +use crate::{GitEvent, RepoAddr}; -/// Window before `now` that an advanced cutoff retreats to. const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); - -/// Window before `now` that a mark-all cutoff retreats to. const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); -/// A thread of notification and own-activity events sharing one root. #[derive(Debug, Clone)] pub struct InboxItem { - /// The root issue, patch or pull request the events belong to. pub root: EventId, - /// The root event itself, when it is known locally. pub root_event: Option, - /// Repository the root belongs to, from the root's `a` tag. pub address: Option, - /// Notification events directed at the user, newest first. pub events: Vec, - /// The user's own events in the thread, newest first. pub own_events: Vec, - /// Unread event ids, oldest first. pub unread_ids: Vec, - /// Whether every notification event in the thread is archived. pub archived: bool, } impl InboxItem { - /// Title of the thread, read from its root issue/patch/PR when known. pub fn title(&self) -> String { self.root_event .as_ref() .or_else(|| self.own_events.first()) .or_else(|| self.events.first()) - .map(activity_subject) + .map(|event| event.activity_subject()) .unwrap_or_else(|| "Untitled".to_string()) } @@ -50,7 +38,6 @@ impl InboxItem { .map(|event| event.kind) } - /// Timestamp of the newest event in the thread. pub fn latest_activity(&self) -> Timestamp { self.root_event .as_ref() @@ -62,7 +49,6 @@ impl InboxItem { .unwrap_or_default() } - /// Up to `limit` events of the thread, oldest first. pub fn timeline(&self, limit: usize) -> Vec { let mut seen: HashSet = HashSet::new(); let mut events: Vec = Vec::new(); @@ -92,7 +78,6 @@ impl InboxItem { events } - /// Whether the thread has an unread event still visible in the inbox. pub fn is_unread(&self) -> bool { !self.archived && !self.unread_ids.is_empty() } @@ -112,44 +97,144 @@ impl InboxItem { } } -/// Root issue, patch or pull request of a notification event. -/// -/// Returns `None` when the event is not git-related, or when its root is a -/// coordinate rather than an event. -/// -/// - issue (1621) / PR (1618): itself -/// - patch (1617): its `e` parent patch, else itself -/// - NIP-22 comment (1111): uppercase `E` root pointer -/// - PR update (1619): uppercase `E` -/// - statuses (1630-1633): NIP-10 root `e` -pub fn notification_root(event: &Event, lookup: &L) -> Option +pub struct ThreadResolver<'a, L: ?Sized> { + lookup: &'a L, +} + +impl<'a, L> ThreadResolver<'a, L> where - L: Fn(EventId) -> Option, + L: Fn(EventId) -> Option + ?Sized, { - match event.kind { - Kind::GitIssue | Kind::GitPullRequest => Some(event.id), - Kind::GitPatch => Some(match first_e_id(event) { - Some(parent) => resolve_thread_root(parent, lookup), - None => event.id, - }), - Kind::Comment => match nip22::extract_root(event) { - Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)), + pub fn new(lookup: &'a L) -> Self { + Self { lookup } + } + + // Kind → root mapping: + // - issue (1621) / PR (1618): itself + // - patch (1617): its `e` parent patch, else itself + // - NIP-22 comment (1111): uppercase `E` root pointer + // - PR update (1619): uppercase `E` + // - statuses (1630-1633): NIP-10 root `e` + // Returns `None` when the event is not git-related, or when its root is a + // coordinate rather than an event. + pub fn notification_root(&self, event: &Event) -> Option { + match event.kind { + Kind::GitIssue | Kind::GitPullRequest => Some(event.id), + Kind::GitPatch => Some(match self.first_e_id(event) { + Some(parent) => self.resolve_thread_root(parent), + None => event.id, + }), + Kind::Comment => match nip22::extract_root(event) { + Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)), + _ => None, + }, + Kind::GitPullRequestUpdate => self + .first_uppercase_e_id(event) + .map(|root| self.resolve_thread_root(root)), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => self + .nip10_root_id(event) + .map(|root| self.resolve_thread_root(root)), _ => None, - }, - Kind::GitPullRequestUpdate => { - first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup)) } - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => { - nip10_root_id(event).map(|root| resolve_thread_root(root, lookup)) + } + + // Follow NIP-10/NIP-22 parent pointers until a root item is reached. + pub fn resolve_thread_root(&self, id: EventId) -> EventId { + let mut seen = HashSet::new(); + let mut root = id; + + loop { + if !seen.insert(root) { + return id; + } + + let Some(event) = (self.lookup)(root) else { + return root; + }; + + if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { + return root; + } + + match self.parent_id(&event) { + Some(parent) => root = parent, + None => return root, + } } - _ => None, + } + + // Mirrors gitworkshop's `getParentId`. + fn parent_id(&self, event: &Event) -> Option { + for marker in ["reply", "root"] { + if let Some(id) = event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, marker)) + { + return Some(id); + } + } + + if let Some(id) = event.tags.iter().find_map(|tag| { + if tag.kind() != "e" { + return None; + } + + let slice = tag.as_slice(); + let is_mention = slice.len() == 4 && slice[3] == "mention"; + + if is_mention { + return None; + } + + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) { + return Some(id); + } + + self.first_uppercase_e_id(event) + } + + fn nip10_root_id(&self, event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, "root")) + .or_else(|| self.first_e_id(event)) + } + + fn first_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "e") + } + + fn first_uppercase_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "E") + } + + fn first_tag_id(&self, event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + if tag.kind() != name { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } + + fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { + let slice = tag.as_slice(); + if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) } } -/// Group notification events and the user's own events into one item per thread. pub fn group( events: E, own: O, @@ -162,12 +247,13 @@ where O: IntoIterator, L: Fn(EventId) -> Option, { + let resolver = ThreadResolver::new(lookup); let mut groups: HashMap> = HashMap::new(); for event in events { if event.pubkey == me { continue; } - let Some(root) = notification_root(&event, lookup) else { + let Some(root) = resolver.notification_root(&event) else { continue; }; groups.entry(root).or_default().push(event); @@ -175,7 +261,7 @@ where let mut own_groups: HashMap> = HashMap::new(); for event in own { - let root = notification_root(&event, lookup).unwrap_or(event.id); + let root = resolver.notification_root(&event).unwrap_or(event.id); own_groups.entry(root).or_default().push(event); } @@ -188,8 +274,8 @@ where .map(|root| { let mut events = groups.remove(&root).unwrap_or_default(); let mut own_events = own_groups.remove(&root).unwrap_or_default(); - sort_newest_first(&mut events); - sort_newest_first(&mut own_events); + utils::sort_newest_first(&mut events); + utils::sort_newest_first(&mut own_events); let root_event = lookup(root); @@ -197,7 +283,8 @@ where root, address: root_event .as_ref() - .and_then(|event| event.tags.coordinates().next()), + .and_then(|event| event.tags.coordinates().next()) + .map(RepoAddr::from), root_event, events, own_events, @@ -218,16 +305,8 @@ where items } -/// Sort thread events newest first, ties broken by id. -fn sort_newest_first(events: &mut [Event]) { - events.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) - }); -} - -/// Read and archive state of the inbox, a high-water-mark model. +// High-water-mark model: events at or before the cutoff +// are covered without an entry in the id set. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InboxReadState { #[serde(default)] @@ -241,31 +320,26 @@ pub struct InboxReadState { } impl InboxReadState { - /// Whether `event` is at or before the read cutoff, or marked read. pub fn is_read(&self, event: &Event) -> bool { event.created_at <= self.read_before || self.read_ids.contains(&event.id) } - /// Whether `event` is at or before the archived cutoff, or marked archived. pub fn is_archived(&self, event: &Event) -> bool { event.created_at <= self.archived_before || self.archived_ids.contains(&event.id) } - /// Mark one event read. Events at or before the cutoff are already read. pub fn mark_read(&mut self, event: &Event) { if event.created_at > self.read_before { self.read_ids.insert(event.id); } } - /// Mark one event archived. Events at or before the cutoff are already archived. pub fn mark_archived(&mut self, event: &Event) { if event.created_at > self.archived_before { self.archived_ids.insert(event.id); } } - /// Mark every non-self event read, anchoring the cutoff ten days back. pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = now - MARK_ALL_WINDOW; self.read_before = cutoff; @@ -276,15 +350,14 @@ impl InboxReadState { .collect(); } - /// Advance the read cutoff to the newest point that keeps unread events - /// unread, then prune the id set. + // Advance the cutoff to the newest point that keeps unread events unread, + // then prune the id set. pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event)); self.read_before = cutoff; prune_ids(&mut self.read_ids, all, cutoff); } - /// Advance the archived cutoff, mirroring [`Self::advance_read`]. pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| { self.is_archived(event) @@ -294,7 +367,6 @@ impl InboxReadState { } } -/// Newest cutoff that keeps unread events unread, never earlier than `current`. fn advance_cutoff( all: &[Event], me: PublicKey, @@ -321,7 +393,6 @@ where candidate.max(current) } -/// Drop ids whose event is unknown or now covered by the cutoff. fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { let created_at: HashMap = all .iter() @@ -330,103 +401,6 @@ fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); } -/// Follow NIP-10/NIP-22 parent pointers until a root item is reached. -fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option) -> EventId { - let mut seen = HashSet::new(); - let mut root = id; - - loop { - if !seen.insert(root) { - return id; - } - - let Some(event) = lookup(root) else { - return root; - }; - - if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { - return root; - } - - match parent_id(&event) { - Some(parent) => root = parent, - None => return root, - } - } -} - -/// Parent of a thread event, mirroring gitworkshop's `getParentId`. -fn parent_id(event: &Event) -> Option { - for marker in ["reply", "root"] { - if let Some(id) = event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, marker)) - { - return Some(id); - } - } - - if let Some(id) = event.tags.iter().find_map(|tag| { - if tag.kind() != "e" { - return None; - } - - let slice = tag.as_slice(); - let is_mention = slice.len() == 4 && slice[3] == "mention"; - - if is_mention { - return None; - } - - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) { - return Some(id); - } - - first_uppercase_e_id(event) -} - -/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. -fn nip10_root_id(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, "root")) - .or_else(|| first_e_id(event)) -} - -/// First `e` tag id, in document order. -fn first_e_id(event: &Event) -> Option { - first_tag_id(event, "e") -} - -/// First uppercase `E` tag id, in document order. -fn first_uppercase_e_id(event: &Event) -> Option { - first_tag_id(event, "E") -} - -fn first_tag_id(event: &Event, name: &str) -> Option { - event.tags.iter().find_map(|tag| { - if tag.kind() != name { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) -} - -/// Event id from a four-element `e` tag carrying `marker`. -fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option { - let slice = tag.as_slice(); - if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) -} - #[cfg(test)] mod tests { use super::*; @@ -489,7 +463,7 @@ mod tests { ); let events = [issue.clone(), comment.clone()]; assert_eq!( - notification_root(&comment, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&comment), Some(issue.id) ); } @@ -500,7 +474,7 @@ mod tests { let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); let events = [root_patch.clone(), child_patch.clone()]; assert_eq!( - notification_root(&child_patch, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&child_patch), Some(root_patch.id) ); } @@ -515,7 +489,10 @@ mod tests { 200, ); let events = [issue.clone(), status.clone()]; - assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&status), + Some(issue.id) + ); } #[test] @@ -524,7 +501,10 @@ mod tests { let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); let events = [issue.clone(), reply, nested.clone()]; - assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&nested), + Some(issue.id) + ); } #[test] @@ -568,7 +548,6 @@ mod tests { assert_eq!(items[0].kind(), Some(Kind::GitIssue)); assert_eq!(items[0].title(), "Add retry logic"); assert_eq!(items[0].events, vec![reply.clone()]); - // The own events are kept apart from the notifications, newest first. assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]); assert_eq!( items[0] @@ -579,94 +558,4 @@ mod tests { vec![issue.id, mine.id, reply.id] ); } - - #[test] - fn mark_all_read_marks_known_recent_events() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let recent = issue(&keys(2), now.as_secs() - 1000); - let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60); - let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60); - let mine = issue(&keys(1), now.as_secs() - 100); - - let mut state = InboxReadState::default(); - state.mark_all_read( - &[recent.clone(), old.clone(), ancient.clone(), mine.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, now - MARK_ALL_WINDOW); - assert_eq!(state.read_ids, HashSet::from([recent.id, old.id])); - assert!(state.is_read(&recent)); - assert!(state.is_read(&ancient)); - assert!(!state.is_read(&mine)); - } - - #[test] - fn advance_read_never_moves_the_cutoff_backwards() { - let me = keys(1); - let unread = issue(&keys(2), 1_000); - let all = [unread]; - let now = Timestamp::from_secs(1_000_000_000); - - let mut state = InboxReadState { - read_before: Timestamp::from_secs(999_999_999), - ..Default::default() - }; - state.advance_read(&all, me.public_key(), now); - - assert_eq!(state.read_before, Timestamp::from_secs(999_999_999)); - } - - #[test] - fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() { - let me = keys(1); - let now = Timestamp::from_secs(1_000_000_000); - let five_days = 5 * 24 * 60 * 60; - let old_unread = issue(&keys(2), now.as_secs() - five_days); - // Read ids that fall before and after the new cutoff. - let stale = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - five_days - 1000, - ); - let fresh = signed( - &keys(2), - Kind::GitIssue, - Vec::new(), - now.as_secs() - 100_000, - ); - - let mut state = InboxReadState { - read_ids: HashSet::from([stale.id, fresh.id]), - ..Default::default() - }; - state.advance_read( - &[old_unread.clone(), stale.clone(), fresh.clone()], - me.public_key(), - now, - ); - - assert_eq!(state.read_before, old_unread.created_at - 1); - assert_eq!(state.read_ids, HashSet::from([fresh.id])); - } - - #[test] - fn mark_archived_skips_events_at_or_before_the_cutoff() { - let now = Timestamp::from_secs(1_000_000_000); - let event = issue(&keys(2), now.as_secs() - 1000); - - let mut state = InboxReadState { - archived_before: now, - ..Default::default() - }; - state.mark_archived(&event); - assert!(state.archived_ids.is_empty()); - - let mut state = InboxReadState::default(); - state.mark_archived(&event); - assert_eq!(state.archived_ids, HashSet::from([event.id])); - } } diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 7232086..328e3fc 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -6,15 +6,10 @@ pub mod model; pub mod state; pub mod status; -pub use addr::{RepoAddr, identifier_from_name, repo_addr}; +pub use addr::RepoAddr; pub use deletions::Deletions; -pub use filters::{ - NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications, -}; -pub use inbox::{InboxItem, InboxReadState, group, notification_root}; -pub use model::{ - Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, - fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, -}; -pub use state::{build_state, parse_state}; -pub use status::{RepoStatus, references_root, resolve_status}; +pub use filters::Filters; +pub use inbox::{InboxItem, InboxReadState, ThreadResolver}; +pub use model::{Announcement, GitEvent, PullRequest}; +pub use state::RepoState; +pub use status::RepoStatus; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 365864f..8baafb4 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -2,44 +2,30 @@ use std::collections::HashSet; use nostr::prelude::*; -use crate::{RepoAddr, repo_addr}; +use crate::RepoAddr; -/// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { - /// ID of the announcement event itself. pub event_id: EventId, - /// Repository ID, the `d` tag. pub id: String, - /// Author of the announcement event. pub owner: PublicKey, - /// When the announcement was published, used for latest-wins resolution. pub created_at: Timestamp, pub name: Option, pub description: Option, - /// Webpage URLs for browsing. pub web: Vec, - /// URLs for `git clone`. pub clone: Vec, - /// Relays the repository monitors for patches and issues. pub relays: Vec, - /// Earliest unique commit ID, the `r` tag with `euc` marker. pub euc: Option, - /// Other recognized maintainers. pub maintainers: Vec, - /// Marks the repository as a subordinate fork of the upstream, per NIP-34. pub upstream: Option, - /// Hashtags labelling the repository, the `t` tags. pub hashtags: Vec, } -/// The `u` tag of a fork announcement, per NIP-34. +// The `u` tag of a fork announcement, per NIP-34. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Upstream { - /// Raw first value of the `u` tag, a coordinate or git URL. pub raw: String, - /// Upstream repository coordinate when the `u` tag names a NIP-34 repository. - /// `None` for the git-URL form. + // `None` for the git-URL form. pub addr: Option, } @@ -49,7 +35,8 @@ impl Upstream { let addr = coordinate .parse::() .ok() - .filter(|c| c.kind == Kind::GitRepoAnnouncement); + .filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement) + .map(RepoAddr::from); Self { raw: raw.to_owned(), addr, @@ -64,216 +51,266 @@ impl Upstream { } } -/// Subject of a NIP-34 issue or pull request event. -/// Taken from the `subject` tag, else the first non-empty line of the content. -pub fn activity_subject(event: &Event) -> String { - let subject = event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Subject(subject)) => Some(subject), - _ => None, - }); +pub trait GitEvent { + fn activity_subject(&self) -> String; - subject - .or_else(|| { - event - .content - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|value| value.to_string()) - }) - .unwrap_or("Untitled".to_string()) + fn current_commit(&self) -> Option; + + fn merge_base(&self) -> Option; + + fn clone_urls(&self) -> Option>; + + fn branch_name(&self) -> Option; + + fn is_git_activity(&self) -> bool; + + // Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers. + fn references_root(&self, root: &EventId) -> bool; } -/// The patch set of a pull request. -/// -/// Returns an empty list when no patch event can be linked to the PR. -pub fn pull_request_patches<'a>( - pr: &Event, - patches: impl IntoIterator, -) -> Vec<&'a Event> { - let patches: Vec<&'a Event> = patches.into_iter().collect(); +impl GitEvent for Event { + fn activity_subject(&self) -> String { + let subject = self + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); + + subject + .or_else(|| { + self.content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|value| value.to_string()) + }) + .unwrap_or("Untitled".to_string()) + } + + fn current_commit(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn merge_base(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn clone_urls(&self) -> Option> { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Clone(urls)) => Some(urls), + _ => None, + }) + } + + fn branch_name(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::BranchName(name)) => Some(name), + _ => None, + }) + } + + fn is_git_activity(&self) -> bool { + match self.kind { + Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, + Kind::Comment => crate::filters::is_git_comment(self), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => crate::filters::is_git_status(self), + _ => false, + } + } + + fn references_root(&self, root: &EventId) -> bool { + let root = root.to_hex(); + self.tags + .iter() + .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) + } +} + +impl GitEvent for &T { + fn activity_subject(&self) -> String { + (*self).activity_subject() + } + + fn current_commit(&self) -> Option { + (*self).current_commit() + } + + fn merge_base(&self) -> Option { + (*self).merge_base() + } + + fn clone_urls(&self) -> Option> { + (*self).clone_urls() + } + + fn branch_name(&self) -> Option { + (*self).branch_name() + } + + fn is_git_activity(&self) -> bool { + (*self).is_git_activity() + } + + fn references_root(&self, root: &EventId) -> bool { + (*self).references_root(root) + } +} + +pub struct PullRequest<'a>(pub &'a Event); + +impl<'a> PullRequest<'a> { + pub fn new(event: &'a Event) -> Self { + Self(event) + } // The PR references its root patch via an `e` tag. - // Follow the NIP-10 reply chain forward from there. - // Each patch replies to the previous one, and among several replies the newest wins. - if let Some(root_id) = pr.tags.event_ids().next() - && let Some(root) = patches.iter().find(|patch| patch.id == root_id) - { - return forward_series(root, &patches); - } + pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); - // The PR has no `e` tag. - // The last patch of the set carries the tip commit in its `commit` or `r` tag. - // Walk the reply chain backward to the root. - let Some(tip) = current_commit_of(pr) else { - return Vec::new(); - }; - let Some(last) = patches - .iter() - .filter(|patch| patch_produces_commit(patch, &tip)) - .max_by_key(|patch| patch.created_at) - .copied() - else { - return Vec::new(); - }; + if let Some(root_id) = pr.tags.event_ids().next() + && let Some(root) = patches.iter().find(|patch| patch.id == root_id) + { + return Self::forward_series(root, &patches); + } - let mut series = vec![last]; - loop { - let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { - break; + let Some(tip) = pr.current_commit() else { + return Vec::new(); }; - let Some(prev) = patches + let Some(last) = patches .iter() - .find(|patch| patch.id == prev_id && !series.contains(patch)) + .filter(|patch| Self::patch_produces_commit(patch, &tip)) + .max_by_key(|patch| patch.created_at) .copied() else { - break; + return Vec::new(); }; - series.push(prev); - } - series.reverse(); - series -} -pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { - let patches: Vec<&'a Event> = patches.into_iter().collect(); - let series = pull_request_patches(pr, patches.iter().copied()); - if series.is_empty() { - return pr.content.clone(); - } - series - .iter() - .map(|patch| patch.content.as_str()) - .collect::>() - .join("\n") -} - -/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. -fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { - let mut series = vec![root]; - loop { - let next = patches - .iter() - .filter(|patch| !series.contains(patch)) - .filter(|patch| { - patch - .tags - .event_ids() - .any(|id| id == series.last().unwrap().id) - }) - .max_by_key(|patch| patch.created_at); - let Some(next) = next else { - break; - }; - series.push(next); - } - series -} - -/// The `c` tag of an event, the tip of the proposed branch, as hex. -pub fn current_commit_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `merge-base` tag of an event, the base commit a pull request diffs against. -pub fn merge_base_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `clone` tag of an event, URLs the tip commit can be fetched from. -pub fn clone_urls_of(event: &Event) -> Option> { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Clone(urls)) => Some(urls), - _ => None, - }) -} - -/// The `branch-name` tag of an event, the proposed branch's name. -pub fn branch_name_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::BranchName(name)) => Some(name), - _ => None, - }) -} - -/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. -/// -/// A pull request's tip is only mutable by its author, per NIP-34; updates -/// from anyone else are ignored even if they are newer. -pub fn latest_update<'a>( - events: impl Iterator, - root: &Event, -) -> Option<&'a Event> { - let root_hex = root.id.to_hex(); - events - .filter(|e| e.kind == Kind::GitPullRequestUpdate) - .filter(|e| e.pubkey == root.pubkey) - .filter(|e| { - e.tags + let mut series = vec![last]; + loop { + let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { + break; + }; + let Some(prev) = patches .iter() - .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) - }) - .max_by_key(|e| e.created_at) -} - -/// The announced forks of `base` a new pull request compare can be built from. -/// -/// The user's own forks are listed first. -pub 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); + .find(|patch| patch.id == prev_id && !series.contains(patch)) + .copied() + else { + break; + }; + series.push(prev); } + series.reverse(); + series } - own.into_iter().chain(others).collect() -} -/// Whether `patch` produces `commit`, found via its `commit` or `r` tag. -/// -/// It lets clients find existing patches for a specific commit. -fn patch_produces_commit(patch: &Event, commit: &str) -> bool { - patch - .tags - .iter() - .any(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, - _ => false, - }) + // Falls back to the root event's content when no patch set is found. + pub fn patch(&self, patches: impl IntoIterator) -> String { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + let series = self.patches(patches.iter().copied()); + if series.is_empty() { + return pr.content.clone(); + } + series + .iter() + .map(|patch| patch.content.as_str()) + .collect::>() + .join("\n") + } + + // A pull request's tip is only mutable by its author per NIP-34, + // updates from anyone else are ignored even if they are newer. + pub fn latest_update( + events: impl Iterator, + root: &Event, + ) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); + events + .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) + .filter(|e| { + e.tags + .iter() + .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) + }) + .max_by_key(|e| e.created_at) + } + + fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { + let mut series = vec![root]; + loop { + let next = patches + .iter() + .filter(|patch| !series.contains(patch)) + .filter(|patch| { + patch + .tags + .event_ids() + .any(|id| id == series.last().unwrap().id) + }) + .max_by_key(|patch| patch.created_at); + let Some(next) = next else { + break; + }; + series.push(next); + } + series + } + + // Lets clients find existing patches for a specific commit. + fn patch_produces_commit(patch: &Event, commit: &str) -> bool { + patch + .tags + .iter() + .any(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, + _ => false, + }) + } } impl Announcement { + // The user's own forks are listed first. + pub fn forks_in<'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() + } + /// Parse a kind `30617` event. /// /// Returns `None` when the kind is wrong or the `d` tag is missing. @@ -337,18 +374,14 @@ impl Announcement { } pub fn addr(&self) -> RepoAddr { - repo_addr(self.owner, self.id.clone()) + RepoAddr::new(self.owner, self.id.clone()) } - /// The name of the repository, or a default if none is provided. pub fn name(&self) -> String { self.name.clone().unwrap_or("Untitled".into()) } - /// Whether this announcement is a fork of the repository at `base`. - /// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged. - /// - /// Or it shares `base`'s earliest unique commit and is not the base itself. + // The `u` tag pointing at `base` also covers permanent forks whose EUC diverged. pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { if self.addr() == *base { return false; @@ -359,17 +392,14 @@ impl Announcement { 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) -> String { self.description .clone() .unwrap_or("No description".to_string()) } - /// The effective maintainers of this repository, - /// the announced `maintainers` plus the announcement author. - /// - /// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34. + // A `u` tag marking the repository as a subordinate fork excludes the + // announcement author from the maintainers, per NIP-34. pub fn effective_maintainers(&self) -> Vec { let mut maintainers = self.maintainers.clone(); if self.upstream.is_none() && !maintainers.contains(&self.owner) { @@ -378,7 +408,6 @@ impl Announcement { maintainers } - /// The `git clone` URLs for this repository, deduplicated. pub fn clone_urls(&self) -> Vec { let mut seen = HashSet::new(); self.clone @@ -472,7 +501,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); - // An invalid URL keeps the whole clone tag from being parsed. assert!(announcement.clone.is_empty()); assert_eq!( announcement.relays, @@ -495,11 +523,9 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let upstream = announcement.upstream.expect("parses the u tag"); - // The coordinate part resolves to a repository address. - // The raw value keeps the `|git-url` suffix. assert_eq!( upstream.addr, - Some(crate::repo_addr( + Some(RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream" )) @@ -516,34 +542,27 @@ 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( + let base = RepoAddr::new( 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. - // This holds 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 with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC. - // Clients of the family can then 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"], @@ -551,15 +570,12 @@ mod tests { 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, the first commit after the fork. - // Only the `u` tag still relates it to the base. - let base = crate::repo_addr( + let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", ); @@ -581,8 +597,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // The owner asserts themselves as a maintainer of the primary project, per NIP-34. - // Announced co-maintainers are included too. assert_eq!(maintainers.len(), 2); assert!(maintainers.contains(&announcement.owner)); assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); @@ -599,8 +613,6 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // A `u` tag marks the repository as a subordinate fork. - // The author is then not a maintainer of the primary project, per NIP-34. assert!(!maintainers.contains(&announcement.owner)); assert_eq!( maintainers, @@ -625,18 +637,16 @@ mod tests { #[test] fn pull_request_patch_joins_the_whole_patch_set() { - // A PR references the root patch, per NIP-34. - // Later patches of the set reply to the previous one via NIP-10 `e` tags. let root = patch_event("patch-one", vec![], 100); let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); let pr = pr_event("description", vec![Tag::event(root.id)]); assert_eq!( - pull_request_patch(&pr, [&root, &second]), + PullRequest::new(&pr).patch([&root, &second]), "patch-one\npatch-two" ); assert_eq!( - pull_request_patches(&pr, [&root, &second]), + PullRequest::new(&pr).patches([&root, &second]), vec![&root, &second] ); } @@ -648,7 +658,7 @@ mod tests { let third = patch_event("patch-three", vec![Tag::event(second.id)], 300); let pr = pr_event("description", vec![Tag::event(root.id)]); - let series = pull_request_patches(&pr, [&third, &root, &second]); + let series = PullRequest::new(&pr).patches([&third, &root, &second]); assert_eq!( series .iter() @@ -660,8 +670,6 @@ mod tests { #[test] fn pull_request_patches_finds_the_set_via_the_tip_commit() { - // PRs without an `e` tag fall back to the patch producing the tip commit. - // Walk the reply chain backward to the root. let root = patch_event("patch-one", vec![], 100); let tip = "1111111111111111111111111111111111111111"; let last = patch_event( @@ -677,7 +685,7 @@ mod tests { vec![Tag::parse(["c", tip]).expect("valid tag")], ); - let series = pull_request_patches(&pr, [&root, &last]); + let series = PullRequest::new(&pr).patches([&root, &last]); assert_eq!( series .iter() @@ -721,7 +729,6 @@ mod tests { created_at, ) }; - // An update revising a different PR must be ignored even though it is newer. let unrelated = signed_at( Kind::GitPullRequestUpdate, vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], @@ -729,7 +736,7 @@ mod tests { ); let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = latest_update(events.iter(), &root).expect("an update"); + let latest = PullRequest::latest_update(events.iter(), &root).expect("an update"); assert_eq!(latest.created_at.as_secs(), 300); assert_eq!(latest.kind, Kind::GitPullRequestUpdate); @@ -749,9 +756,7 @@ mod tests { .finalize(&other) .expect("signed event"); - // The tip of a PR is only mutable by its author. - // A newer update from anyone else must not win. - assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); + assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); } const OWNER_KEYS: [&str; 3] = [ @@ -784,12 +789,10 @@ mod tests { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let clone = "https://grasp.example/npub1x/my-fork.git"; - let base_addr = crate::repo_addr( + let base_addr = RepoAddr::new( PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), "upstream", ); - // Newest first, as RepoListStore keeps them. - // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag. let all = vec![ owned_announcements( 2, @@ -819,7 +822,7 @@ mod tests { ]; let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user)); let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["my-fork", "their-fork"]); @@ -829,7 +832,7 @@ mod tests { 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 = crate::repo_addr(base_owner, "upstream"); + let base_addr = RepoAddr::new(base_owner, "upstream"); let mut all = vec![ owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) @@ -859,11 +862,10 @@ mod tests { .unwrap(), ]; - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + let forks = Announcement::forks_in(&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( owned_announcements( 2, @@ -876,7 +878,7 @@ mod tests { .pop() .unwrap(), ); - let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let forks = Announcement::forks_in(&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/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 1e36932..16f444b 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,44 +1,45 @@ use nostr::prelude::*; -/// Build a kind `30618` repository state event from refs and HEAD, -/// it is published as `ref: refs/heads/`. -/// -/// The `d` tag matches the repository id. -pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { - let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; - for (name, commit) in refs { - tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); - } - if let Some(head) = head { - tags.push( - Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"), - ); - } - EventBuilder::new(Kind::RepoState, "").tags(tags) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoState { + pub refs: Vec<(String, String)>, + pub head: Option, } -/// Parse a kind `30618` repository state event into refs and HEAD. -/// -/// `refs` are `(refname, commit-id)` pairs. -/// `head` is the branch pointed to by the `HEAD` tag, if any. -pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { - let mut refs = Vec::new(); - let mut head = None; - - for tag in event.tags.iter() { - match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Head(branch)) => head = Some(branch), - Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); - } - Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); - } - _ => {} +impl RepoState { + // The `d` tag matches the repository id. + pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { + let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; + for (name, commit) in refs { + tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); } + if let Some(head) = head { + tags.push( + Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"), + ); + } + EventBuilder::new(Kind::RepoState, "").tags(tags) } - (refs, head) + pub fn parse(event: &Event) -> Self { + let mut refs = Vec::new(); + let mut head = None; + + for tag in event.tags.iter() { + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} + } + } + + Self { refs, head } + } } #[cfg(test)] @@ -76,11 +77,11 @@ mod tests { &["refs/tags/v1.0", COMMIT_A], ]); - let (refs, head) = parse_state(&event); + let state = RepoState::parse(&event); - assert_eq!(head.as_deref(), Some("main")); + assert_eq!(state.head.as_deref(), Some("main")); assert_eq!( - refs, + state.refs, vec![ ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), @@ -90,22 +91,22 @@ mod tests { } #[test] - fn build_state_round_trips_through_parse() { + fn build_round_trips_through_parse() { let refs = [ ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), ]; - let event = build_state("my-repo", &refs, Some("main")) + let event = RepoState::build("my-repo", &refs, Some("main")) .finalize(&keys()) .expect("signed event"); assert_eq!(event.kind, Kind::RepoState); assert_eq!(event.tags.identifier().as_deref(), Some("my-repo")); - let (parsed_refs, head) = parse_state(&event); - assert_eq!(parsed_refs, refs); - assert_eq!(head.as_deref(), Some("main")); + let state = RepoState::parse(&event); + assert_eq!(state.refs, refs); + assert_eq!(state.head.as_deref(), Some("main")); } } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 4ce9e57..17f73d9 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -1,6 +1,5 @@ use nostr::prelude::*; -/// Status of a root patch, pull request or issue, kinds `1630..=1633`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RepoStatus { Open, @@ -28,42 +27,30 @@ impl RepoStatus { Self::Draft => Kind::GitStatusDraft, } } -} -/// NIP-10 and NIP-34 use the lowercase `e` tag. -/// -/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root. -pub fn references_root(event: &Event, root: &EventId) -> bool { - let root = root.to_hex(); - event - .tags - .iter() - .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) -} - -/// Resolve the status of a root event per NIP-34. -/// -/// Defaults to [`RepoStatus::Open`]. -pub fn resolve_status<'a, I>( - status_events: I, - root_author: &PublicKey, - maintainers: &[PublicKey], -) -> RepoStatus -where - I: IntoIterator, -{ - status_events - .into_iter() - .filter(|e| RepoStatus::from_kind(e.kind).is_some()) - .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) - .max_by_key(|e| e.created_at) - .and_then(|e| RepoStatus::from_kind(e.kind)) - .unwrap_or(RepoStatus::Open) + // Defaults to Open when no authorized status event exists. + pub fn resolve<'a, I>( + status_events: I, + root_author: &PublicKey, + maintainers: &[PublicKey], + ) -> Self + where + I: IntoIterator, + { + status_events + .into_iter() + .filter(|e| RepoStatus::from_kind(e.kind).is_some()) + .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) + .max_by_key(|e| e.created_at) + .and_then(|e| RepoStatus::from_kind(e.kind)) + .unwrap_or(RepoStatus::Open) + } } #[cfg(test)] mod tests { use super::*; + use crate::GitEvent; const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222"; @@ -94,11 +81,8 @@ mod tests { )) .expect("signed event"); - assert!(references_root(&event, &root)); - assert!(!references_root( - &event, - &EventId::from_hex(OTHER_ID_HEX).expect("valid id") - )); + assert!(event.references_root(&root)); + assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id"))); } #[test] @@ -115,7 +99,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] @@ -140,7 +124,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index 2ae2e78..5cc04d2 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -3,9 +3,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use signed_core::{Announcement, RepoAddr}; -use crate::remote::{clone_repo, fetch_all}; +use crate::repo::Repo; -/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. #[derive(Debug, Clone)] pub struct GitCache { root: PathBuf, @@ -22,30 +21,25 @@ impl GitCache { pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root - .join(addr.public_key.to_hex()) - .join(sanitize_path_component(&addr.identifier)) + .join(addr.public_key().to_hex()) + .join(Self::sanitize_path_component(addr.identifier())) } - pub fn open(&self, addr: &RepoAddr) -> Result> { + pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match gix::open(&path) { - Ok(repo) => Ok(Some(repo)), + Ok(repo) => Ok(Some(Repo { inner: repo })), Err(gix::open::Error::NotARepository { .. }) => Ok(None), Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } - /// Open the existing clone, fetching it first. - pub fn ensure_clone>( - &self, - addr: &RepoAddr, - clone_urls: &[U], - ) -> Result { + pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { let path = self.repo_path(addr); if let Some(repo) = self.open(addr)? { - fetch_all(&repo).ok(); + repo.fetch().ok(); return Ok(repo); } @@ -54,37 +48,35 @@ impl GitCache { .with_context(|| format!("failed to create {}", parent.display()))?; } - clone_repo(clone_urls, &path)?; - self.open(addr)? - .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) - } -} - -/// Map an untrusted repository id or display name to a safe single path component. -pub fn sanitize_path_component(id: &str) -> String { - let sanitized: String = id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - - if sanitized == "." || sanitized == ".." { - return "_".to_owned(); + Repo::clone(clone_urls, &path) } - sanitized -} + // `id` is untrusted relay content: it must never escape the cache root as + // a single path component. + pub fn sanitize_path_component(id: &str) -> String { + let sanitized: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); -/// The refs namespace of a fork's import in the target mirror. -pub fn fork_namespace(announcement: &Announcement) -> String { - format!( - "{}/{}", - announcement.owner.to_hex(), - sanitize_path_component(&announcement.id) - ) + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized + } + + pub fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + Self::sanitize_path_component(&announcement.id) + ) + } } diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index 8e0afb3..81e799a 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -1,11 +1,10 @@ -use std::path::Path; - use anyhow::Result; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; +use crate::repo::Repo; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { - /// An unchanged context line, present on both sides. Context, Addition, Deletion, @@ -14,21 +13,15 @@ pub enum DiffLineKind { #[derive(Debug, Clone)] pub struct DiffLine { pub kind: DiffLineKind, - /// 1-based line number in the old version, if the line exists there. pub old: Option, - /// 1-based line number in the new version, if the line exists there. pub new: Option, - /// Line content without the trailing newline. pub text: String, } -/// A hunk of a file diff, like `@@ -a,b +c,d @@`. #[derive(Debug, Clone)] pub struct DiffHunk { - /// 1-based start line in the old version. pub old_start: u32, pub old_lines: u32, - /// 1-based start line in the new version. pub new_start: u32, pub new_lines: u32, pub lines: Vec, @@ -45,18 +38,13 @@ pub enum DiffStatus { #[derive(Debug, Clone)] pub struct FileDiff { - /// Path of the file relative to the repo root. - /// - /// For renames and copies, this is the destination path. + // For renames and copies, the destination path. pub path: String, - /// Previous path, for renames and copies. pub old_path: Option, pub status: DiffStatus, - /// Number of added lines, 0 for binary files. pub insertions: usize, - /// Number of removed lines, 0 for binary files. pub deletions: usize, - /// True if either version is binary, then `hunks` is empty. + // Binary files have empty `hunks`. pub binary: bool, pub hunks: Vec, } @@ -66,162 +54,158 @@ pub struct CommitDiff { pub files: Vec, } -/// The changes of the commit `id`, short or full, in the repository at `workdir`. -/// -/// Compared against its first parent, the empty tree for the root commit. -pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&gix::open(workdir)?, id) -} - -fn commit_diff(repo: &gix::Repository, id: &str) -> Result { - let commit_id = repo.rev_parse_single(id.as_bytes())?; - let commit = commit_id.object()?.into_commit(); - let new_tree = commit.tree()?; - let old_tree = match commit.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - tree_diff(repo, old_tree.as_ref(), &new_tree) -} - -/// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// -/// Directories and submodules are skipped, files are sorted by path. -pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = gix::open(workdir)?; - let base_tree = repo - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = repo - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - tree_diff(&repo, Some(&base_tree), &tip_tree) -} - -fn tree_diff( - repo: &gix::Repository, - old_tree: Option<&gix::Tree<'_>>, - new_tree: &gix::Tree<'_>, -) -> Result { - use gix::diff::blob::platform::prepare_diff::Operation; - use gix::object::tree::diff::Change; - use gix::objs::tree::EntryKind; - - let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?; - - let mut cache = repo.diff_resource_cache_for_tree_diff()?; - let mut files = Vec::new(); - - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), repo, repo); - - // Skip directory trees and submodule gitlinks, only files are listed. - let (path, old_path, status) = match attached { - Change::Addition { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Added) - } - Change::Deletion { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Deleted) - } - Change::Modification { - location, - previous_entry_mode, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - previous_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - (location.to_owned(), None, DiffStatus::Modified) - } - Change::Rewrite { - location, - source_location, - source_entry_mode, - entry_mode, - copy, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - source_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - let status = if copy { - DiffStatus::Copied - } else { - DiffStatus::Renamed - }; - ( - location.to_owned(), - Some(source_location.to_owned()), - status, - ) - } - _ => continue, +impl Repo { + // Compared against the first parent; the empty tree for the root commit. + pub fn commit_diff(&self, id: &str) -> Result { + let commit_id = self.inner.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, }; - - // Always diff with the built-in algorithm. - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff()?; - - let (binary, hunks, insertions, deletions) = match outcome.operation { - Operation::InternalDiff { algorithm } => { - let input = outcome.interned_input(); - let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); - - let mut hunks = Vec::new(); - let mut insertions = 0usize; - let mut deletions = 0usize; - let collector = HunkCollector { - hunks: &mut hunks, - insertions: &mut insertions, - deletions: &mut deletions, - }; - gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) - .consume()?; - (false, hunks, insertions, deletions) - } - Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), - Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"), - }; - - files.push(FileDiff { - path: String::from_utf8_lossy(&path).into_owned(), - old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), - status, - insertions, - deletions, - binary, - hunks, - }); + Self::tree_diff(self, old_tree.as_ref(), &new_tree) } - files.sort_by(|a, b| a.path.cmp(&b.path)); + // Directories and submodules are skipped, files are sorted by path. + pub fn range_diff(&self, base: &str, tip: &str) -> Result { + let base_tree = self + .inner + .rev_parse_single(base.as_bytes())? + .object()? + .into_commit() + .tree()?; + let tip_tree = self + .inner + .rev_parse_single(tip.as_bytes())? + .object()? + .into_commit() + .tree()?; + Self::tree_diff(self, Some(&base_tree), &tip_tree) + } - Ok(CommitDiff { files }) + fn tree_diff( + repo: &Repo, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, + ) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let changes = repo + .inner + .diff_tree_to_tree(old_tree, Some(new_tree), None)?; + + let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?; + let mut files = Vec::new(); + + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); + + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + // External diff drivers would shell out, out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector { + hunks: &mut hunks, + insertions: &mut insertions, + deletions: &mut deletions, + }; + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => { + unreachable!("external diff drivers are disabled") + } + }; + + files.push(FileDiff { + path: String::from_utf8_lossy(&path).into_owned(), + old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + }); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(CommitDiff { files }) + } } -/// Collects the hunks of one blob diff while tracking per-line numbers. struct HunkCollector<'a> { hunks: &'a mut Vec, insertions: &'a mut usize, diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index f99a105..d26c916 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -3,246 +3,194 @@ use std::path::{Path, PathBuf}; use anyhow::Result; -/// In-memory object cache for history walks, see [`open_with_cache`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. -const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; +use crate::repo::Repo; -/// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] pub struct FileCommit { - /// Shortened commit id, 7+ hex chars, disambiguated if needed. pub id: String, - /// First line of the commit message. pub summary: String, - /// Rest of the commit message after the title. - /// - /// `None` for single-line commit messages. pub description: Option, pub author: String, - /// Author time, seconds since the Unix epoch. pub time: i64, } -/// Open the repository at `workdir` with an in-memory object cache. -/// -/// Only history walks use it, they re-decode the same commit objects repeatedly. -/// Single-object reads open the repository plain. -pub(crate) fn open_with_cache(workdir: &Path) -> Result { - let mut repo = gix::open(workdir)?; - repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(repo) -} - -/// A [`FileCommit`] with author, message title, body and shortened id. -/// -/// The diff panel fetches the full commit on demand. -fn file_commit(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, true) -} - -/// A [`FileCommit`] without the message body, for history lists that never display it. -/// -/// Skipping the body saves an allocation per listed commit. -fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, false) -} - -fn file_commit_with_description( - commit: &gix::Commit<'_>, - include_description: bool, -) -> Result { - let author = commit.author()?; - let message = commit.message()?; - - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { - message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()) - } else { - None - }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }) -} - -/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. -/// `rels` are paths relative to the worktree. -/// -/// Paths without any commit, like untracked files, are absent from the result. -pub fn worktree_last_commits( - workdir: &Path, - rels: &[PathBuf], -) -> Result> { - last_commits(&open_with_cache(workdir)?, rels) -} - -/// Stops as soon as every pending path has its commit. -fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); - }; - - let mut pending: Vec = Vec::with_capacity(rels.len()); - let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); - - for rel in rels { - if seen.insert(rel.as_path()) { - pending.push(rel.clone()); - } +impl FileCommit { + fn from_commit(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, true) } - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); + // History lists never display the body, + // skipping it saves an allocation per listed commit. + fn from_commit_summary(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, false) + } - let mut found = Vec::new(); - for info in walk.all()? { - if pending.is_empty() { - break; - } - let info = info?; - let commit = info.object()?; - let tree = commit.tree()?; - let parent_tree = match info.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, + fn from_commit_with_description( + commit: &gix::Commit<'_>, + include_description: bool, + ) -> Result { + let author = commit.author()?; + let message = commit.message()?; + + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) + } +} + +impl Repo { + // Paths without any commit, like untracked files, are absent from the result. + pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(Vec::new()); }; - // Compare each unresolved path against this commit and its first parent. - let mut ix = 0; - while ix < pending.len() { - let rel = &pending[ix]; - let blob = tree.lookup_entry_by_path(rel)?; - let parent_blob = match &parent_tree { - Some(tree) => tree.lookup_entry_by_path(rel)?, + let mut pending: Vec = Vec::with_capacity(rels.len()); + let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); + + for rel in rels { + if seen.insert(rel.as_path()) { + pending.push(rel.clone()); + } + } + + let walk = self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut found = Vec::new(); + for info in walk.all()? { + if pending.is_empty() { + break; + } + let info = info?; + let commit = info.object()?; + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), None => None, }; - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) - { - found.push((rel.clone(), file_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; + // Compare each unresolved path against this commit and its first parent. + let mut ix = 0; + while ix < pending.len() { + let rel = &pending[ix]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + Some(tree) => tree.lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) + != parent_blob.map(|entry| entry.id().detach()) + { + found.push((rel.clone(), FileCommit::from_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } } } + + Ok(found) } - Ok(found) + pub fn all_commits(&self) -> Result { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); + }; + + let walk = self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + let mut total = 0; + + for info in walk.all()? { + let info = info?; + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + } + + Ok(CommitList { total, commits }) + } + + pub fn commit_range(&self, base: &str, tip: &str) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let base_id = self.inner.rev_parse_single(base.as_bytes())?; + let tip_id = self.inner.rev_parse_single(tip.as_bytes())?; + let walk = self + .inner + .rev_walk([tip_id]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )) + .with_hidden([base_id]); + + let mut commits = Vec::new(); + + for info in walk.all()? { + let info = info?; + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + + Ok(commits) + } + + // `Ok(None)` for an unborn HEAD. + pub fn head_commit(&self) -> Result> { + let Some(head) = self.inner.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + + // `Ok(None)` when the id cannot be resolved. + pub fn commit(&self, id: &str) -> Result> { + match self.inner.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + Err(_) => Ok(None), + } + } } -/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, -/// the tab badge shows the real count. -/// -/// A huge history is never fully materialized in memory. +// The virtual list renders a window at a time, the tab badge shows the real +// count: a huge history is never fully materialized in memory. pub const MAX_LISTED_COMMITS: usize = 20_000; -/// Commits reachable from `HEAD`, newest first, possibly capped. pub struct CommitList { - /// Number of commits reachable from HEAD. pub total: usize, - /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. pub commits: Vec, } - -/// All commits reachable from `HEAD`, newest first, with author and summary. -/// -/// Returns an empty list for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut commits = Vec::new(); - let mut total = 0; - - for info in walk.all()? { - let info = info?; - total += 1; - if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit_summary(&info.object()?)?); - } - } - - Ok(CommitList { total, commits }) -} - -/// For non-bare clones the clone root is the worktree. -pub fn worktree_all_commits(workdir: &Path) -> Result { - all_commits(&open_with_cache(workdir)?) -} - -/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. -pub fn worktree_commit_range_commits( - workdir: &Path, - base: &str, - tip: &str, -) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let repo = open_with_cache(workdir)?; - let base_id = repo.rev_parse_single(base.as_bytes())?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - let walk = repo - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - - for info in walk.all()? { - let info = info?; - commits.push(file_commit_summary(&info.object()?)?); - } - - Ok(commits) -} -/// The commit HEAD points to, like `git log -1`. -/// -/// `Ok(None)` for a repository without commits yet, an unborn HEAD. -pub fn head_commit(repo: &gix::Repository) -> Result> { - let Some(head) = repo.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) -} - -/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. -/// Like [`head_commit`] for an arbitrary commit. -/// -/// `Ok(None)` when the id cannot be resolved. -pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = gix::open(workdir)?; - match repo.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) - } - Err(_) => Ok(None), - } -} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 470aaee..c967fe4 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -11,41 +11,18 @@ mod worktree; #[cfg(test)] mod tests; -pub use cache::{GitCache, fork_namespace, sanitize_path_component}; -pub use diff::{ - CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff, - worktree_commit_range_diff, -}; -pub use history::{ - CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits, - worktree_commit, worktree_commit_range_commits, worktree_last_commits, -}; -pub use nip34::{ - GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo, -}; -pub use patch::{ - apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series, -}; -pub use remote::{ - clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref, - push_main, remote_has_refs, set_origin, -}; -pub use repo::{ - RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches, - head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state, - repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists, - worktree_ref_state, -}; +pub use cache::GitCache; +pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; +pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; +pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind}; +pub use patch::PatchParser; +pub use repo::{Repo, RepoRefState}; pub use scan::{LocalRepo, find_git_repos}; -pub use worktree::{ - WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, - worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot, -}; +pub use worktree::WorktreeSnapshot; -/// The terminal prompt is disabled so a credential request fails instead of hanging. #[cfg(test)] fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { - let output = remote::git_output(dir, args, "git")?; + let output = Repo::run_git(dir, args, "git")?; if !output.status.success() { anyhow::bail!( diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index 7c50c13..d81caa2 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -1,17 +1,17 @@ -use std::path::Path; - use anyhow::Result; use gix::bstr::ByteSlice; use nostr::prelude::*; +use crate::repo::Repo; + /// The kind of NIP-34 relationship a local repository has on disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nip34Kind { - /// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`. + // By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`. Initialized, - /// Cloned from a `nostr://` remote but never initialized locally. + // Cloned from a `nostr://` remote but never initialized locally. Cloned, - /// Nostr tooling touched the repository but no binding is recoverable. + // Nostr tooling touched the repository but no binding is recoverable. ToolingOnly, } @@ -22,7 +22,7 @@ pub struct GraspSignals { pub nostr_repo_config: bool, pub nostr_remote: bool, pub grasp_remote: bool, - /// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/` remote name. + // `nak`-specific `nip34/grasp/` remote name. pub nip34_grasp_remote: bool, pub nip34_state_refs: bool, pub nostr_cache: bool, @@ -41,7 +41,7 @@ impl GraspSignals { pub struct Nip34Binding { pub kind: Nip34Kind, pub signals: GraspSignals, - /// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`. + // From `nip34.json` or `nostr.repo`. pub owner: Option, pub identifier: Option, pub grasp_urls: Vec, @@ -53,222 +53,221 @@ struct Nip34Json { owner: Option, } -pub fn detect_nip34(repo_path: &Path) -> Option { - let repo = gix::open(repo_path).ok()?; - let common_dir = repo.common_dir().to_path_buf(); - let workdir = repo.workdir().map(Path::to_path_buf); +impl Repo { + // `None` when no Nostr tooling left a marker. + pub fn nip34_binding(&self) -> Option { + let common_dir = self.inner.common_dir().to_path_buf(); + let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); - let mut signals = GraspSignals::default(); - let mut owner: Option = None; - let mut identifier: Option = None; - let mut grasp_urls: Vec = Vec::new(); + let mut signals = GraspSignals::default(); + let mut owner: Option = None; + let mut identifier: Option = None; + let mut grasp_urls: Vec = Vec::new(); - if let Some(workdir) = &workdir { - if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) - && let Ok(config) = serde_json::from_slice::(&bytes) - { - signals.nip34_json = true; - identifier = config.identifier.and_then(non_empty); - owner = config - .owner - .as_deref() - .and_then(|value| PublicKey::parse(value).ok()); - } + if let Some(workdir) = &workdir { + if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) + && let Ok(config) = serde_json::from_slice::(&bytes) + { + signals.nip34_json = true; + identifier = config.identifier.and_then(Self::non_empty); + owner = config + .owner + .as_deref() + .and_then(|value| PublicKey::parse(value).ok()); + } - if workdir.join("maintainers.yaml").is_file() { - signals.maintainers_yaml = true; - } - } - - if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) - && exclude.contains("nip34.json") - { - signals.nip34_excluded = true; - } - - // `ngit` keeps its repository event cache in the Git common directory. - if common_dir.join("nostr-cache.lmdb").is_file() { - signals.nostr_cache = true; - } - - // `ngit` reads and writes `nostr.repo` at repository-local scope only. - if let Ok(config) = gix::config::File::from_path_no_includes( - common_dir.join("config"), - gix::config::Source::Local, - ) { - if let Some(value) = config.string("nostr.repo") - && let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy()) - { - signals.nostr_repo_config = true; - owner = Some(key); - identifier = Some(id); - } - - for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { - if config.string(key).is_some() { - signals.nostr_aux_config = true; + if workdir.join("maintainers.yaml").is_file() { + signals.maintainers_yaml = true; } } - if let Some(sections) = config.sections_by_name("remote") { - for section in sections { - let Some(name) = section.header().subsection_name() else { - continue; - }; - let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); + if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) + && exclude.contains("nip34.json") + { + signals.nip34_excluded = true; + } - for url in section.values("url") { - let url = url.to_str_lossy(); + // `ngit` keeps its repository event cache in the Git common directory. + if common_dir.join("nostr-cache.lmdb").is_file() { + signals.nostr_cache = true; + } - if url.starts_with("nostr://") { - signals.nostr_remote = true; - // Strong markers win; only fill an empty binding. - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = parse_nostr_url(&url) - { - owner = Some(key); - identifier = Some(id); + // `ngit` reads and writes `nostr.repo` at repository-local scope only. + if let Ok(config) = gix::config::File::from_path_no_includes( + common_dir.join("config"), + gix::config::Source::Local, + ) { + if let Some(value) = config.string("nostr.repo") + && let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy()) + { + signals.nostr_repo_config = true; + owner = Some(key); + identifier = Some(id); + } + + for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { + if config.string(key).is_some() { + signals.nostr_aux_config = true; + } + } + + if let Some(sections) = config.sections_by_name("remote") { + for section in sections { + let Some(name) = section.header().subsection_name() else { + continue; + }; + let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); + + for url in section.values("url") { + let url = url.to_str_lossy(); + + if url.starts_with("nostr://") { + signals.nostr_remote = true; + // Strong markers win; only fill an empty binding. + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::parse_nostr_url(&url) + { + owner = Some(key); + identifier = Some(id); + } } - } - if is_grasp_url(&url) { - signals.grasp_remote = true; - signals.nip34_grasp_remote |= nak_grasp_remote; - grasp_urls.push(url.to_string()); + if Self::is_grasp_url(&url) { + signals.grasp_remote = true; + signals.nip34_grasp_remote |= nak_grasp_remote; + grasp_urls.push(url.to_string()); - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = grasp_parts(&url) - { - owner = Some(key); - identifier = Some(id); + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::grasp_parts(&url) + { + owner = Some(key); + identifier = Some(id); + } } } } } } + + // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. + if let Ok(platform) = self.inner.references() + && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") + && refs.next().is_some() + { + signals.nip34_state_refs = true; + } + + if !signals.any() { + return None; + } + + let kind = if signals.nip34_json + || signals.nostr_repo_config + || signals.nip34_grasp_remote + || signals.nip34_state_refs + { + Nip34Kind::Initialized + } else if signals.nostr_remote { + Nip34Kind::Cloned + } else { + Nip34Kind::ToolingOnly + }; + + Some(Nip34Binding { + kind, + signals, + owner, + identifier, + grasp_urls, + }) } - // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. - if let Ok(platform) = repo.references() - && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") - && refs.next().is_some() - { - signals.nip34_state_refs = true; + pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { + self.edit_local_config(|config| { + config.set_raw_value("nostr.repo", naddr)?; + Ok(()) + }) } - if !signals.any() { - return None; + // Mirrors `nak`'s `IsGraspURL`: two path segments, + // a path of at least 65 bytes, and a first segment that decodes as an `npub`. + fn is_grasp_url(url: &str) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + + if !matches!(parsed.scheme(), "http" | "https" | "grasp") { + return false; + } + + let path = parsed.path(); + if path.matches('/').count() != 2 || path.len() < 65 { + return false; + } + + Self::grasp_parts(url).is_some() } - let kind = if signals.nip34_json - || signals.nostr_repo_config - || signals.nip34_grasp_remote - || signals.nip34_state_refs - { - Nip34Kind::Initialized - } else if signals.nostr_remote { - Nip34Kind::Cloned - } else { - Nip34Kind::ToolingOnly - }; + fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { + let parsed = Url::parse(url).ok()?; + let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); - Some(Nip34Binding { - kind, - signals, - owner, - identifier, - grasp_urls, - }) -} + let owner = PublicKey::parse(segments.next()?).ok()?; + let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; -/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config. -pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - - crate::remote::edit_local_config(&repo, |config| { - config.set_raw_value("nostr.repo", naddr)?; - Ok(()) - }) -} - -/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes, -/// and a first segment that decodes as an `npub`. -pub fn is_grasp_url(url: &str) -> bool { - let Ok(parsed) = Url::parse(url) else { - return false; - }; - - if !matches!(parsed.scheme(), "http" | "https" | "grasp") { - return false; + Some((owner, identifier)) } - let path = parsed.path(); - if path.matches('/').count() != 2 || path.len() < 65 { - return false; + fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { + let coordinate = Nip19Coordinate::from_bech32(value).ok()?; + if coordinate.kind != Kind::GitRepoAnnouncement { + return None; + } + + let identifier = Self::non_empty(coordinate.identifier.clone())?; + Some((coordinate.public_key, identifier)) } - grasp_parts(url).is_some() -} + // Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, + // `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. + fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { + let rest = url.strip_prefix("nostr://")?; -fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { - let parsed = Url::parse(url).ok()?; - let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); + if rest.starts_with("naddr1") { + return Self::coordinate_from_naddr(rest); + } - let owner = PublicKey::parse(segments.next()?).ok()?; - let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?; + let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); + let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - Some((owner, identifier)) -} + if parts + .first() + .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) + { + parts.remove(0); + } -fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { - let coordinate = Nip19Coordinate::from_bech32(value).ok()?; - if coordinate.kind != Kind::GitRepoAnnouncement { - return None; + if parts.len() < 2 { + return None; + } + + let owner = PublicKey::parse(parts[0]).ok()?; + let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?; + + Some((owner, identifier)) } - let identifier = non_empty(coordinate.identifier.clone())?; - Some((coordinate.public_key, identifier)) -} - -/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`, -/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding. -fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { - let rest = url.strip_prefix("nostr://")?; - - if rest.starts_with("naddr1") { - return coordinate_from_naddr(rest); + fn non_empty(value: impl Into) -> Option { + let value = value.into(); + (!value.is_empty()).then_some(value) } - - let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); - let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - - if parts - .first() - .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) - { - parts.remove(0); - } - - // `[owner, (relay), identifier]`. - if parts.len() < 2 { - return None; - } - - let owner = PublicKey::parse(parts[0]).ok()?; - let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?; - - Some((owner, identifier)) -} - -fn non_empty(value: impl Into) -> Option { - let value = value.into(); - (!value.is_empty()).then_some(value) } #[cfg(test)] mod tests { + use std::path::Path; use std::process::Command; use super::*; @@ -306,10 +305,14 @@ mod tests { .expect("naddr") } + fn binding_of(path: &Path) -> Option { + Repo::open(path).ok()?.nip34_binding() + } + #[test] fn plain_repository_has_no_binding() { let (_dir, path) = init_repo(); - assert!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -323,7 +326,7 @@ mod tests { ) .expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_json); assert_eq!(binding.owner, Some(owner)); @@ -335,7 +338,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join("nip34.json"), b"not json").expect("write"); - assert!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -351,7 +354,7 @@ mod tests { &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_excluded); assert!(binding.signals.nip34_state_refs); @@ -364,7 +367,7 @@ mod tests { let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); git(&path, &["config", "nostr.repo", &naddr]); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); @@ -377,9 +380,12 @@ mod tests { let owner = key(); let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); - set_nostr_repo(&path, &naddr).expect("write marker"); + Repo::open(&path) + .expect("open") + .set_nostr_repo(&naddr) + .expect("write marker"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); @@ -394,7 +400,7 @@ mod tests { let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); git(&path, &["remote", "add", "origin", &url]); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Cloned); assert!(binding.signals.nostr_remote); assert_eq!(binding.owner, Some(owner)); @@ -412,7 +418,7 @@ mod tests { &["config", "remote.nip34/grasp/gitnostr.com.url", &url], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_grasp_remote); assert!(binding.signals.grasp_remote); @@ -426,7 +432,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::ToolingOnly); assert!(binding.signals.nostr_cache); } @@ -436,18 +442,18 @@ mod tests { let owner = key(); let npub = owner.to_bech32().expect("npub"); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "https://gitnostr.com/{npub}/my-repo.git" ))); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "grasp://gitnostr.com/{npub}/my-repo.git" ))); - assert!(!is_grasp_url("https://gitnostr.com/my-repo.git")); - assert!(!is_grasp_url( + assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git")); + assert!(!Repo::is_grasp_url( "https://gitnostr.com/not-a-pubkey/my-repo.git" )); - assert!(!is_grasp_url(&format!( + assert!(!Repo::is_grasp_url(&format!( "ssh://gitnostr.com/{npub}/my-repo.git" ))); } diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs index cf67bdc..2331924 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -1,6 +1,5 @@ use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Stdio; use anyhow::{Context, Result, bail}; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; @@ -8,315 +7,342 @@ use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; +use crate::repo::Repo; -/// Apply a `git format-patch` patch or series with `git am`. -/// -/// Uses the git CLI because it handles the mbox format natively. -/// -/// TODO: replace with a pure-Rust implementation later without changing callers. -pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { - let mut child = Command::new("git") - .arg("am") - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; +pub struct PatchParser; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - - if !output.status.success() { - bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); +impl PatchParser { + // A malformed input yields one element covering it. + pub fn split_patch_series(patch: &str) -> Vec<&str> { + Self::envelopes(patch) + .into_iter() + .map(|message| message.text) + .collect() } - Ok(()) -} - -/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. -/// Fails when the range has no commits. -/// -/// The mbox is returned untrimmed. Trailing newlines are part of the format. -pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git format-patch`")?; - - if !output.status.success() { - bail!( - "git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - - if patch.trim().is_empty() { - bail!("no commits between {base} and {tip}"); - } - - Ok(patch) -} - -/// Split a `git format-patch` series into its individual patches, mbox messages. -/// -/// A single patch yields one element. -/// A malformed input yields one element covering it. -pub fn split_patch_series(patch: &str) -> Vec<&str> { - let mut starts = vec![0usize]; - let mut search_from = 1; - - while let Some(rel) = patch[search_from..].find("\nFrom ") { - let ix = search_from + rel + 1; - let hex = patch[ix + 5..] - .split(|c: char| !c.is_ascii_hexdigit()) - .next() - .unwrap_or(""); - if hex.len() == 40 { - starts.push(ix); + // Backed by `diffy::patch_set`, which implements git's extended diff format: + // rename and copy detection, binary detection, quoted/escaped paths. + pub fn patch_diffs(patch: &str) -> Result { + if !patch.lines().any(|line| line.starts_with("diff --git ")) { + return Ok(CommitDiff { files: Vec::new() }); } - search_from = ix + 1; + + let mut files = Vec::new(); + + for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { + files.push(Self::file_diff(file?)?); + } + + Ok(CommitDiff { files }) } - starts - .iter() - .enumerate() - .map(|(i, &start)| { - let end = starts.get(i + 1).copied().unwrap_or(patch.len()); - &patch[start..end] + // Entries appear in patch order, oldest first as `git format-patch` + // produces them. + pub fn patch_commits(patch: &str) -> Vec { + Self::envelopes(patch) + .into_iter() + .filter(|message| !message.id.is_empty()) + .map(|message| FileCommit { + id: message.id.to_string(), + summary: message + .header("Subject") + .map(Self::strip_patch_prefix) + .unwrap_or_default(), + description: None, + author: message + .header("From") + .map(Self::name_from_address) + .unwrap_or_default(), + time: message + .header("Date") + .and_then(|value| gix::date::parse(value.trim(), None).ok()) + .map(|time| time.seconds) + .unwrap_or(0), + }) + .collect() + } + + // The one parser `split_patch_series` and `patch_commits` share. + // A malformed input yields one message covering the whole input. + fn envelopes(patch: &str) -> Vec> { + let mut messages: Vec> = Vec::new(); + let mut current: Option<(usize, &str, Vec<&str>)> = None; + let mut headers_closed = false; + + let mut offset = 0usize; + for line in patch.lines() { + let line_start = offset; + offset += line.len() + 1; + + let is_envelope = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .is_some_and(|id| id.len() == 40); + + if is_envelope { + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + let id = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .unwrap_or(""); + + current = Some((line_start, id, Vec::new())); + headers_closed = false; + continue; + } + + if let Some((_, _, headers)) = &mut current { + if headers_closed { + continue; + } + if line.is_empty() { + headers_closed = true; + } else { + headers.push(line); + } + } + } + + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + if messages.is_empty() { + messages.push(Envelope { + text: patch, + id: "", + headers: Vec::new(), + }); + } + + messages + } + + fn name_from_address(from: &str) -> String { + match from.trim().find('<') { + Some(ix) => from[..ix].trim().to_string(), + None => from.trim().to_string(), + } + } + + // Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc. + fn strip_patch_prefix(subject: &str) -> String { + let trimmed = subject.trim(); + let Some(rest) = trimmed.strip_prefix('[') else { + return trimmed.to_string(); + }; + let Some(end) = rest.find(']') else { + return trimmed.to_string(); + }; + if rest[..end].to_ascii_lowercase().contains("patch") { + rest[end + 1..].trim().to_string() + } else { + trimmed.to_string() + } + } + + fn file_diff(file: FilePatch<'_, str>) -> Result { + // The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way + // `git apply -p1` does; rename and copy paths come from their own + // headers, unprefixed. + let stripped; + let operation = match file.operation() { + operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, + operation => { + stripped = operation.strip_prefix(1); + &stripped + } + }; + + let (path, old_path, status) = match operation { + FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), + FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), + FileOperation::Modify { modified, .. } => { + (modified.as_ref(), None, DiffStatus::Modified) + } + FileOperation::Rename { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) + } + FileOperation::Copy { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied) + } + }; + + let mut insertions = 0usize; + let mut deletions = 0usize; + let mut hunks = Vec::new(); + + let patch = file.patch(); + + if let Some(text) = patch.as_text() { + for hunk in text.hunks() { + let hunk = Self::hunk_diff(hunk); + insertions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Addition) + .count(); + deletions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Deletion) + .count(); + hunks.push(hunk); + } + } + + Ok(FileDiff { + path: path.to_owned(), + old_path: old_path.map(str::to_owned), + status, + insertions, + deletions, + binary: patch.is_binary(), + hunks, }) - .collect() -} - -/// Parse `git format-patch` output, a single patch or a series. -/// -/// Backed by [`diffy::patch_set`], which implements git's extended diff format: -/// `diff --git` headers, rename and copy detection, binary detection, and -/// C-style quoted or octal-escaped paths. -pub fn patch_diffs(patch: &str) -> Result { - if !patch.lines().any(|line| line.starts_with("diff --git ")) { - return Ok(CommitDiff { files: Vec::new() }); } - let mut files = Vec::new(); + // `diffy` reports only the hunk header ranges; the per-line numbers are + // counted from them the way the header encodes them. + fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { + let old_range = hunk.old_range(); + let new_range = hunk.new_range(); - for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { - files.push(file_diff(file?)?); - } + let mut old = old_range.start() as u32; + let mut new = new_range.start() as u32; + let mut lines = Vec::with_capacity(hunk.lines().len()); - Ok(CommitDiff { files }) -} + for line in hunk.lines() { + let (kind, text) = match line { + Line::Context(text) => (DiffLineKind::Context, *text), + Line::Delete(text) => (DiffLineKind::Deletion, *text), + Line::Insert(text) => (DiffLineKind::Addition, *text), + }; -fn file_diff(file: FilePatch<'_, str>) -> Result { - // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path - // component is dropped, the same way `git apply -p1` does. - // Rename and copy paths come from their own headers, unprefixed. - let stripped; - let operation = match file.operation() { - operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, - operation => { - stripped = operation.strip_prefix(1); - &stripped + let (old_no, new_no) = match kind { + DiffLineKind::Context => { + let numbers = (Some(old), Some(new)); + old += 1; + new += 1; + numbers + } + DiffLineKind::Addition => { + let number = Some(new); + new += 1; + (None, number) + } + DiffLineKind::Deletion => { + let number = Some(old); + old += 1; + (number, None) + } + }; + + lines.push(DiffLine { + kind, + old: old_no, + new: new_no, + text: Self::line_text(text), + }); } - }; - let (path, old_path, status) = match operation { - FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), - FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), - FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified), - FileOperation::Rename { from, to } => { - (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) - } - FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied), - }; - - let mut insertions = 0usize; - let mut deletions = 0usize; - let mut hunks = Vec::new(); - - let patch = file.patch(); - - if let Some(text) = patch.as_text() { - for hunk in text.hunks() { - let hunk = hunk_diff(hunk); - insertions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Addition) - .count(); - deletions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Deletion) - .count(); - hunks.push(hunk); + DiffHunk { + old_start: old_range.start() as u32, + old_lines: old_range.len() as u32, + new_start: new_range.start() as u32, + new_lines: new_range.len() as u32, + lines, } } - Ok(FileDiff { - path: path.to_owned(), - old_path: old_path.map(str::to_owned), - status, - insertions, - deletions, - binary: patch.is_binary(), - hunks, - }) -} - -/// The [`DiffHunk`] of one parsed hunk, including the line number of every line. -/// -/// `diffy` reports only the hunk header ranges. The per-line numbers are -/// counted from them the way the header encodes them: context lines advance -/// both sides, deletions only the old, insertions only the new. -fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { - let old_range = hunk.old_range(); - let new_range = hunk.new_range(); - - let mut old = old_range.start() as u32; - let mut new = new_range.start() as u32; - let mut lines = Vec::with_capacity(hunk.lines().len()); - - for line in hunk.lines() { - let (kind, text) = match line { - Line::Context(text) => (DiffLineKind::Context, *text), - Line::Delete(text) => (DiffLineKind::Deletion, *text), - Line::Insert(text) => (DiffLineKind::Addition, *text), - }; - - let (old_no, new_no) = match kind { - DiffLineKind::Context => { - let numbers = (Some(old), Some(new)); - old += 1; - new += 1; - numbers - } - DiffLineKind::Addition => { - let number = Some(new); - new += 1; - (None, number) - } - DiffLineKind::Deletion => { - let number = Some(old); - old += 1; - (number, None) - } - }; - - lines.push(DiffLine { - kind, - old: old_no, - new: new_no, - text: line_text(text), - }); - } - - DiffHunk { - old_start: old_range.start() as u32, - old_lines: old_range.len() as u32, - new_start: new_range.start() as u32, - new_lines: new_range.len() as u32, - lines, + fn line_text(text: &str) -> String { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_suffix('\r').unwrap_or(text).to_owned() } } -/// The content of a parsed line without its line ending. -/// -/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. -fn line_text(text: &str) -> String { - let text = text.strip_suffix('\n').unwrap_or(text); - text.strip_suffix('\r').unwrap_or(text).to_owned() +// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. +struct Envelope<'a> { + text: &'a str, + id: &'a str, + headers: Vec<&'a str>, } -/// Commits of a `git format-patch` output, a single patch or a series. -/// -/// Entries appear in patch order, oldest first as `git format-patch` produces them. -pub fn patch_commits(patch: &str) -> Vec { - let lines: Vec<&str> = patch.lines().collect(); +impl Envelope<'_> { + fn header(&self, name: &str) -> Option<&str> { + let prefix = format!("{name}: "); + self.headers + .iter() + .find(|line| line.starts_with(&prefix)) + .map(|line| &line[prefix.len()..]) + } +} - let mut commits = Vec::new(); - let mut i = 0; +impl Repo { + // The git CLI handles the mbox format natively. + // TODO: replace with a pure-Rust implementation later without changing callers. + pub fn apply_patch(&self, patch: &str) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); - while i < lines.len() { - // A patch starts with its `From ` envelope line. - let Some(rest) = lines[i].strip_prefix("From ") else { - i += 1; - continue; - }; + let mut child = Repo::git_command(&workdir) + .args(["am"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; - let Some(id) = rest.split_whitespace().next() else { - i += 1; - continue; - }; + child + .stdin + .as_mut() + .context("git am has no stdin pipe")? + .write_all(patch.as_bytes())?; - if id.len() != 40 { - i += 1; - continue; + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); } - let mut author = String::new(); - let mut summary = String::new(); - let mut time = 0i64; + Ok(()) + } - // Envelope headers run up to the blank line before the commit message. - i += 1; - while i < lines.len() && !lines[i].is_empty() { - let header = lines[i]; - if let Some(value) = header.strip_prefix("From: ") { - author = name_from_address(value); - } else if let Some(value) = header.strip_prefix("Subject: ") { - summary = strip_patch_prefix(value); - } else if let Some(value) = header.strip_prefix("Date: ") { - time = gix::date::parse(value.trim(), None) - .map(|t| t.seconds) - .unwrap_or(0); - } - i += 1; + // Fails when the range has no commits. The mbox is returned untrimmed; + // trailing newlines are part of the format. + pub fn format_patch_between(&self, base: &str, tip: &str) -> Result { + let output = Repo::run_git( + self.workdir_or_dot(), + &["format-patch", "--stdout", &format!("{base}..{tip}")], + "git format-patch", + )?; + + if !output.status.success() { + bail!( + "git format-patch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); } - commits.push(FileCommit { - id: id.to_string(), - summary, - description: None, - author, - time, - }); - } + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - commits -} + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } -fn name_from_address(from: &str) -> String { - match from.trim().find('<') { - Some(ix) => from[..ix].trim().to_string(), - None => from.trim().to_string(), - } -} - -/// Strip the patch prefix from a `Subject:` header. -/// -/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. -fn strip_patch_prefix(subject: &str) -> String { - let trimmed = subject.trim(); - let Some(rest) = trimmed.strip_prefix('[') else { - return trimmed.to_string(); - }; - let Some(end) = rest.find(']') else { - return trimmed.to_string(); - }; - if rest[..end].to_ascii_lowercase().contains("patch") { - rest[end + 1..].trim().to_string() - } else { - trimmed.to_string() + Ok(patch) } } diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 78d2452..7170444 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -6,346 +6,276 @@ use anyhow::{Context, Result, bail}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; -/// Clone into `path` from the first working URL in `clone_urls`. -/// -/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. -pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { - if path.exists() { - bail!("destination {} already exists", path.display()); - } +use crate::repo::Repo; - try_each_url(clone_urls, "clone", |url| { - let repo = clone(url, path)?; - // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - Ok(()) - }) -} - -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -pub fn fetch_all(repo: &gix::Repository) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - )? - .to_owned(), - ], - ..Default::default() - }; - repo.find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) -} - -pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["push"]) - .arg(url) - .arg(format!("{commit}:{reference}")) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Rewrite a grasp server URL to the https URL the git transport actually uses. -/// -/// GRASP servers announce `grasp:////` clone URLs. -/// The transport is git smart HTTP, so the scheme is rewritten for gix. -fn transport_url(url: &str) -> String { - url.strip_prefix("grasp://") - .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()) -} - -/// Run `attempt` against each URL in `urls` until one succeeds. -/// -/// Returns the last error wrapped in `failed to {verb} from any mirror`, -/// or `no clone URLs provided` when the list is empty. -fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let mut last_err: Option = None; - - for url in urls { - match attempt(url.as_ref()) { - Ok(()) => return Ok(()), - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), - None => bail!("no clone URLs provided"), - } -} - -fn clone(url: &str, path: &Path) -> Result { - let url = transport_url(url); - let url = gix::url::parse(url).context("invalid clone URL")?; - - let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; - let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - - Ok(repo) -} - -pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) -} - -/// Push every local branch and tag of the repository at `repo_path` to a grasp server. -/// -/// This mirrors an initialized repository's whole history. -pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) -} - -fn push_refspecs( - repo_path: &Path, - base_url: &str, - owner: &str, - repo_id: &str, - refspecs: &[&str], -) -> Result<()> { - let url = format!("{base_url}/{owner}/{repo_id}.git"); - - let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); - args.push("push"); - args.push(&url); - args.extend_from_slice(refspecs); - - let output = git_output(repo_path, &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Whether `url` advertises every ref in `expected` at the given commit. -/// -/// Extra advertised refs are ignored: the question is whether the data this -/// push wanted to land is already there, not whether the remote is an exact mirror. -/// This is the convergence probe for a push that lost the compare-and-swap race -/// to the grasp server's own background ref alignment. -pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let repo = gix::open(repo_path)?; - let url = transport_url(url); - - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, - // like `git ls-remote ` would; ref maps never write to the repository. - let refspecs = expected - .iter() - .map(|(name, _)| { - gix::refspec::parse( - gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), - gix::refspec::parse::Operation::Fetch, - ) - .map(|spec| spec.to_owned()) - }) - .collect::, _>>() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = repo - .remote_at(url.as_str()) - .with_context(|| format!("cannot use remote {url}"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("cannot connect to {url}"))? - .ref_map(Discard, options) - .with_context(|| format!("listing refs of {url} failed"))?; - - // Peeled tag entries carry the tag object in their direct oid, so mapping - // each advertised ref to its direct oid matches `git ls-remote` while - // skipping the duplicated `^{}` lines. - let advertised: HashMap = refs - .remote_refs - .iter() - .filter_map(|reference| { - let (name, object, _peeled) = reference.unpack(); - object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) - }) - .collect(); - - Ok(expected - .iter() - .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) -} - -/// Add `origin` pointing at `url` when the repository has no remote yet. -/// -/// No-op if `origin` already exists. -pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - if repo.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - edit_local_config(&repo, |config| { - config.set_raw_value("remote.origin.url", url)?; - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - Ok(()) - }) -} - -/// Point `origin` at `url`, replacing an existing remote, -/// used after a clone whose `origin` points at the cloned-from path. -/// -/// A working copy cloned from a local mirror is re-targeted at the grasp server. -pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let had_origin = repo.find_remote("origin").is_ok(); - - edit_local_config(&repo, |config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. - config.set_raw_value("remote.origin.url", url)?; - - if !had_origin { - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - } - - Ok(()) - }) -} - -/// Apply `edit` to the repository-local configuration and persist it. -pub(crate) fn edit_local_config( - repo: &gix::Repository, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, -) -> Result<()> { - let config_path = repo.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .context("failed to lock repository config")?; - - let mut config = - match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { - Ok(config) => config, - // A repository without a config file yet starts from scratch. - Err(gix::config::file::init::from_paths::Error::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - gix::config::File::default() - } - Err(error) => return Err(error).context("failed to read repository config"), - }; - - edit(&mut config)?; - - config - .write_to(&mut lock) - .context("failed to write repository config")?; - - lock.commit().context("failed to save repository config")?; - - Ok(()) -} - -/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// 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: &[U], refspec: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .context("invalid fetch refspec")? - .to_owned(); - - try_each_url(urls, "fetch", |url| { - let url = transport_url(url); +impl Repo { + pub fn fetch(&self) -> Result<()> { let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + )? + .to_owned(), + ], ..Default::default() }; - repo.remote_at(url.as_str()) - .with_context(|| format!("fetch from {url} failed"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("fetch from {url} failed"))? - .prepare_fetch(Discard, options) - .with_context(|| format!("fetch from {url} failed"))? - .receive(Discard, &IS_INTERRUPTED) - .with_context(|| format!("fetch from {url} failed"))?; + self.inner + .find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; Ok(()) - }) -} - -/// The URL of the `origin` remote of the repository at `workdir`. -/// -/// `None` when it has no `origin` yet. -pub fn origin_url(workdir: &Path) -> Result> { - let Ok(repo) = gix::open(workdir) else { - return Ok(None); - }; - - let Ok(remote) = repo.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) -} - -/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. -/// -/// `what` names the command in the spawn error. -pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { - Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) + } + + pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { + let output = Self::run_git( + self.workdir_or_dot(), + &["push", url, &format!("{commit}:{reference}")], + "git push", + )?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) + } + + pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) + } + + fn push_refspecs( + &self, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], + ) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + // The convergence probe for a push that lost the compare-and-swap race to + // the grasp server's own background ref alignment. Extra advertised refs + // are ignored: the question is whether the pushed data is already there, + // not whether the remote is an exact mirror. + pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { + if expected.is_empty() { + return Ok(true); + } + + let url = Self::transport_url(url); + + // A URL-created remote has no configured fetch refspecs, and `ref_map` + // only keeps refs matching one; match each expected ref by exact name. + let refspecs = expected + .iter() + .map(|(name, _)| { + gix::refspec::parse( + gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), + gix::refspec::parse::Operation::Fetch, + ) + .map(|spec| spec.to_owned()) + }) + .collect::, _>>() + .context("invalid refspec")?; + + let options = gix::remote::ref_map::Options { + extra_refspecs: refspecs, + ..Default::default() + }; + + let (refs, _) = self + .inner + .remote_at(url.as_str()) + .with_context(|| format!("cannot use remote {url}"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("cannot connect to {url}"))? + .ref_map(Discard, options) + .with_context(|| format!("listing refs of {url} failed"))?; + + // Peeled tag entries carry the tag object in their direct oid, matching + // `git ls-remote` while skipping the duplicated `^{}` lines. + let advertised: HashMap = refs + .remote_refs + .iter() + .filter_map(|reference| { + let (name, object, _peeled) = reference.unpack(); + object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) + }) + .collect(); + + Ok(expected + .iter() + .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) + } + + pub fn ensure_origin(&self, url: &str) -> Result<()> { + if self.inner.find_remote("origin").is_ok() { + return Ok(()); + } + + // `git remote add` also configures the default fetch refspec. + self.edit_local_config(|config| { + config.set_raw_value("remote.origin.url", url)?; + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + Ok(()) + }) + } + + // A working copy cloned from a local mirror is re-targeted at the + // grasp server; a pre-existing fetch refspec is left untouched. + pub fn set_origin(&self, url: &str) -> Result<()> { + let had_origin = self.inner.find_remote("origin").is_ok(); + + self.edit_local_config(|config| { + config.set_raw_value("remote.origin.url", url)?; + + if !had_origin { + config + .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + } + + Ok(()) + }) + } + + pub fn origin_url(&self) -> Result> { + let Ok(remote) = self.inner.find_remote("origin") else { + return Ok(None); + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(|url| url.to_string())) + } + + // Never touches the checked-out refs or the worktree. The last error is + // returned when no URL works. + pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { + let refspec = gix::refspec::parse( + gix::bstr::BStr::new(refspec), + gix::refspec::parse::Operation::Fetch, + ) + .context("invalid fetch refspec")? + .to_owned(); + + let mut last_error = None; + + for url in urls { + let url = Self::transport_url(url.as_ref()); + let outcome = (|| -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![refspec.clone()], + ..Default::default() + }; + self.inner + .remote_at(url.as_str()) + .with_context(|| format!("fetch from {url} failed"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("fetch from {url} failed"))? + .prepare_fetch(Discard, options) + .with_context(|| format!("fetch from {url} failed"))? + .receive(Discard, &IS_INTERRUPTED) + .with_context(|| format!("fetch from {url} failed"))?; + Ok(()) + })(); + + match outcome { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to fetch from any mirror"), + None => bail!("no fetch URLs provided"), + } + } + + pub(crate) fn edit_local_config( + &self, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, + ) -> Result<()> { + let config_path = self.inner.common_dir().join("config"); + + let mut lock = gix::lock::File::acquire_to_update_resource( + &config_path, + gix::lock::acquire::Fail::Immediately, + None, + ) + .context("failed to lock repository config")?; + + let mut config = + match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) + { + Ok(config) => config, + Err(gix::config::file::init::from_paths::Error::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + gix::config::File::default() + } + Err(error) => return Err(error).context("failed to read repository config"), + }; + + edit(&mut config)?; + + config + .write_to(&mut lock) + .context("failed to write repository config")?; + + lock.commit().context("failed to save repository config")?; + + Ok(()) + } + + pub(crate) fn workdir_or_dot(&self) -> &Path { + self.inner.workdir().unwrap_or_else(|| Path::new(".")) + } + + pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { + Self::git_command(dir) + .args(args) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) + } + + pub(crate) fn git_command(dir: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); + command + } } diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 93d5998..fe6184f 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -2,342 +2,397 @@ use std::path::Path; use anyhow::{Context, Result}; -use crate::history::open_with_cache; -use crate::worktree::{force_checkout, worktree_dirty}; +// History walks re-decode the same commit objects without one; sized +// generously, a walk can cover a large portion of the history. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; -/// The merge base of two revisions in the repository at `repo_path`, -/// revisions may be branch names, remote-tracking refs or commit ids. -/// -/// `Ok(None)` when the revisions share no common ancestor. -/// -/// Unresolvable revisions are errors. -pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let repo = open_with_cache(repo_path)?; - let a = repo.rev_parse_single(a.as_bytes())?; - let b = repo.rev_parse_single(b.as_bytes())?; - match repo.merge_base(a, b) { - Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. - Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), - Err(e) => Err(e.into()), - } +// One `open` per operation instead of every helper re-opening by path. +pub struct Repo { + pub(crate) inner: gix::Repository, } -/// The commit HEAD points to in the repository at `repo_path`. -/// -/// `None` when the repository has no commits yet, an unborn HEAD. -pub fn head_commit_id(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - match repo.head_id() { - Ok(id) => Ok(Some(id.to_string())), - Err(_) => Ok(None), - } -} - -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. -/// This is the order `git am` creates them. -/// -/// `HEAD` alone when `base` is `None`. -pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { - let repo = match gix::open(repo_path) { - Ok(repo) => repo, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - - let head = match repo.head_id() { - Ok(head) => head, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e).context("repository has no commits"), - }; - - let Some(base) = base else { - return Ok(vec![head.to_string()]); - }; - - let base = repo.rev_parse_single(base.as_bytes())?; - let mut commits = Vec::new(); - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .with_hidden([base]) - .all()? - { - commits.push(info?.id().to_string()); - } - - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. - commits.reverse(); - - Ok(commits) -} - -/// The identity written to reflogs and commits created by this crate itself. -/// -/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works -/// without a global git identity, and `gix` runs no hooks and never signs. -pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default(); - - let signature = gix::actor::Signature { - name: gix::bstr::BString::from("Signed"), - email: gix::bstr::BString::from("signed@localhost"), - time: gix::date::Time { seconds, offset: 0 }, - }; - - (signature, gix::date::parse::TimeBuf::default()) -} - -/// Create a repository at `path` with an initial `main` branch. -/// Write a `README.md` from `name` and `description`, then create the initial commit. -/// -/// Returns the initial commit id. -pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - std::fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - - let repo = gix::init(path)?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "checkout: moving to main".into(), - }, - expected: PreviousValue::Any, - new: gix::refs::Target::Symbolic( - gix::refs::FullName::try_from("refs/heads/main") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, - ), - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - let readme = if description.trim().is_empty() { - format!("# {name}\n") - } else { - format!("# {name}\n\n{description}\n") - }; - - std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; - - let blob = repo.write_object(gix::objs::Blob { - data: readme.into_bytes(), - })?; - - let tree = repo.write_object(gix::objs::Tree { - entries: vec![gix::objs::tree::Entry { - mode: gix::objs::tree::EntryKind::Blob.into(), - filename: gix::bstr::BString::from("README.md"), - oid: blob.into(), - }], - })?; - - let commit = repo.commit_as( - signature, - signature, - "HEAD", - "Initial commit", - tree, - Vec::::new(), - )?; - - // Populate the index so the fresh repository is clean, - // as `git add` and `git commit` would leave it. - let mut index = repo.index_from_tree(&tree)?; - index.write(gix::index::write::Options::default())?; - - Ok(commit.to_string()) -} - -/// The earliest unique commit of the repository at `repo_path`. -/// Used as the NIP-34 announcement's `euc` marker. -/// -/// `None` for a repository without commits. -pub fn root_commit(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - let Ok(head) = repo.head_id() else { - return Ok(None); - }; - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all()? - { - let info = info?; - if info.parent_ids().next().is_none() { - return Ok(Some(info.id().to_string())); - } - } - - Ok(None) -} - -/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. -/// `prefix` is a ref namespace like `refs/fork//`. -/// -/// Returns an empty list when nothing matches. -pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { - let pattern = prefix.trim_end_matches('/'); - let repo = gix::open(repo_path)?; - let mut names = Vec::new(); - - for reference in repo.references()?.all()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - - // Match the pattern itself and everything beneath it, like `git for-each-ref`. - let under_pattern = name - .strip_prefix(pattern) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); - - if under_pattern { - names.push(name); - } - } - - names.sort(); - - Ok(names) -} - -/// Delete every ref under `prefix` of the repository at `repo_path`. -/// `prefix` is a ref namespace like `refs/fork//`. -pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { - use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; - - let refs = refs_with_prefix(repo_path, prefix)?; - if refs.is_empty() { - return Ok(()); - } - - let repo = gix::open(repo_path)?; - let edits: Vec = refs - .iter() - .map(|name| { - let full = gix::refs::FullName::try_from(name.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; - Ok(RefEdit { - change: Change::Delete { - expected: PreviousValue::Any, - log: RefLog::AndReference, - }, - name: full, - deref: false, - }) +impl Repo { + pub fn open(workdir: &Path) -> Result { + Ok(Self { + inner: gix::open(workdir)?, }) - .collect::>>()?; + } - repo.edit_references(edits)?; + pub fn try_open(workdir: &Path) -> Option { + Self::open(workdir).ok() + } - Ok(()) -} + // Only history walks benefit from the object cache, they re-decode the + // same commit objects repeatedly; single-object reads open plain. + pub fn open_cached(workdir: &Path) -> Result { + let mut repo = gix::open(workdir)?; + repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); + Ok(Self { inner: repo }) + } -/// Short name of the branch HEAD points to at `workdir`, -/// `None` when detached or unreadable, like `git branch --show-current`. -pub fn worktree_current_branch(workdir: &Path) -> Option { - let repo = gix::open(workdir).ok()?; - let head = repo.head().ok()?; - let name = head.referent_name()?; - Some(String::from_utf8_lossy(name.shorten()).into_owned()) -} + pub fn init(path: &Path, name: &str, description: &str) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; -pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - repo.find_reference(name).is_ok() -} + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; -/// Fast-forward local branches that trail their remote-tracking counterpart. -/// -/// Returns whether any branch moved. -pub fn fast_forward_branches(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let current = worktree_current_branch(workdir); - let heads = refs_with_prefix(workdir, "refs/heads")?; + let repo = gix::init(path)?; - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); - let mut moved = false; - - for head in heads { - let Some(branch) = head.strip_prefix("refs/heads/") else { - continue; - }; - - let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. - let Ok(mut remote_reference) = repo.find_reference(&remote) else { - continue; - }; - - let Ok(mut local_reference) = repo.find_reference(&head) else { - continue; - }; - - let Ok(remote_oid) = remote_reference.peel_to_id() else { - continue; - }; - - let Ok(local_oid) = local_reference.peel_to_id() else { - continue; - }; - - let remote_oid = remote_oid.detach(); - let local_oid = local_oid.detach(); - - if local_oid == remote_oid { - continue; - } - - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. - let Ok(base) = repo.merge_base(local_oid, remote_oid) else { - continue; - }; - - if base != local_oid { - continue; - } - - let full = gix::refs::FullName::try_from(head.as_str()) + // The initial branch is `main` regardless of `init.defaultBranch`: + // point the unborn HEAD there. + let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - let edit = |new: gix::refs::Target| { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - RefEdit { + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "checkout: moving to main".into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Symbolic( + gix::refs::FullName::try_from("refs/heads/main") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, + ), + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + + std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; + + let blob = repo.write_object(gix::objs::Blob { + data: readme.into_bytes(), + })?; + + let tree = repo.write_object(gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: gix::bstr::BString::from("README.md"), + oid: blob.into(), + }], + })?; + + let commit = repo.commit_as( + signature, + signature, + "HEAD", + "Initial commit", + tree, + Vec::::new(), + )?; + + // Populate the index so the fresh repository is clean, as `git add` + // and `git commit` would leave it. + let mut index = repo.index_from_tree(&tree)?; + index.write(gix::index::write::Options::default())?; + + Ok(commit.to_string()) + } + + // Not kept in any cache, unlike `GitCache::ensure_clone`. + pub fn clone>(clone_urls: &[U], path: &Path) -> Result { + if path.exists() { + anyhow::bail!("destination {} already exists", path.display()); + } + + let mut last_error = None; + + for url in clone_urls { + match Self::clone_from(url.as_ref(), path) { + Ok(repo) => { + // The default-refspec clone misses the `refs/nostr/*` PR refs. + repo.fetch().ok(); + return Ok(repo); + } + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to clone from any mirror"), + None => anyhow::bail!("no clone URLs provided"), + } + } + + fn clone_from(url: &str, path: &Path) -> Result { + let url = Self::transport_url(url); + let url = gix::url::parse(url).context("invalid clone URL")?; + + let mut prepare = gix::prepare_clone(url, path)?; + let (mut checkout, _fetch) = + prepare.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; + let (repo, _checkout) = + checkout.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; + + Ok(Self { inner: repo }) + } + + pub fn inner(&self) -> &gix::Repository { + &self.inner + } + + pub fn workdir(&self) -> Option<&Path> { + self.inner.workdir() + } + + // `None` for an unborn HEAD. + pub fn head(&self) -> Option { + self.inner.head_id().ok().map(|id| id.to_string()) + } + + // `Ok(None)` when the revisions share no common ancestor — a valid + // outcome for a proposal; unresolvable revisions are errors. + pub fn merge_base(&self, a: &str, b: &str) -> Result> { + let a = self.inner.rev_parse_single(a.as_bytes())?; + let b = self.inner.rev_parse_single(b.as_bytes())?; + match self.inner.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // Oldest first: the order `git am` creates them. + pub fn commits_since(&self, base: Option<&str>) -> Result> { + let head = match self.inner.head_id() { + Ok(head) => head, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e).context("repository has no commits"), + }; + + let Some(base) = base else { + return Ok(vec![head.to_string()]); + }; + + let base = self.inner.rev_parse_single(base.as_bytes())?; + let mut commits = Vec::new(); + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .with_hidden([base]) + .all()? + { + commits.push(info?.id().to_string()); + } + + commits.reverse(); + + Ok(commits) + } + + // The NIP-34 announcement's `euc` marker; `None` without commits. + pub fn root_commit(&self) -> Result> { + let Ok(head) = self.inner.head_id() else { + return Ok(None); + }; + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all()? + { + let info = info?; + if info.parent_ids().next().is_none() { + return Ok(Some(info.id().to_string())); + } + } + + Ok(None) + } + + pub fn refs_with_prefix(&self, prefix: &str) -> Result> { + let pattern = prefix.trim_end_matches('/'); + let mut names = Vec::new(); + + for reference in self.inner.references()?.all()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); + + // Match the pattern itself and everything beneath it. + let under_pattern = name + .strip_prefix(pattern) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + + if under_pattern { + names.push(name); + } + } + + names.sort(); + + Ok(names) + } + + pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> { + use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; + + let refs = self.refs_with_prefix(prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let edits: Vec = refs + .iter() + .map(|name| { + let full = gix::refs::FullName::try_from(name.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; + Ok(RefEdit { + change: Change::Delete { + expected: PreviousValue::Any, + log: RefLog::AndReference, + }, + name: full, + deref: false, + }) + }) + .collect::>>()?; + + self.inner.edit_references(edits)?; + + Ok(()) + } + + pub fn current_branch(&self) -> Option { + let head = self.inner.head().ok()?; + let name = head.referent_name()?; + Some(String::from_utf8_lossy(name.shorten()).into_owned()) + } + + pub fn ref_exists(&self, name: &str) -> bool { + self.inner.find_reference(name).is_ok() + } + + pub fn branches(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + pub fn tags(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + pub fn ref_state(&self) -> Result { + let mut refs = Vec::new(); + + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match self.inner.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) + } + + // Fast-forward only: local-only commits or diverged history must never + // be rewritten by a refresh. Returns whether any branch moved. + pub fn fast_forward_branches(&self) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + if self.workdir().is_none() { + return Ok(false); + } + + let current = self.current_branch(); + let heads = self.refs_with_prefix("refs/heads")?; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + + let remote = format!("refs/remotes/origin/{branch}"); + let Ok(mut remote_reference) = self.inner.find_reference(&remote) else { + continue; + }; + + let Ok(mut local_reference) = self.inner.find_reference(&head) else { + continue; + }; + + let Ok(remote_oid) = remote_reference.peel_to_id() else { + continue; + }; + + let Ok(local_oid) = local_reference.peel_to_id() else { + continue; + }; + + let remote_oid = remote_oid.detach(); + let local_oid = local_oid.detach(); + + if local_oid == remote_oid { + continue; + } + + // Only fast-forward: the base must be the local tip. + let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else { + continue; + }; + + if base != local_oid { + continue; + } + + let full = gix::refs::FullName::try_from(head.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let edit = |new: gix::refs::Target| RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, @@ -351,119 +406,65 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { }, name: full.clone(), deref: false, + }; + + if current.as_deref() == Some(branch) { + // Only proceed on a clean worktree, like `git merge --ff-only`. + if self.is_dirty() { + continue; + } + + let tree = self.inner.find_object(remote_oid)?.peel_to_tree()?.id; + + self.force_checkout(&tree)?; + + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; + } else { + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; } + } + + Ok(moved) + } + + // Like `git -c user.name=… -c user.email=…` per invocation: the repository + // works without a global git identity, and `gix` runs no hooks and never signs. + pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let signature = gix::actor::Signature { + name: gix::bstr::BString::from("Signed"), + email: gix::bstr::BString::from("signed@localhost"), + time: gix::date::Time { seconds, offset: 0 }, }; - if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. - // Only proceed on a clean worktree, like `git merge --ff-only`. - if worktree_dirty(workdir) { - continue; - } - - let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; - - force_checkout(&repo, &tree)?; - - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } else { - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } + (signature, gix::date::parse::TimeBuf::default()) } - Ok(moved) -} - -/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. -pub fn repo_branches(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + // GRASP servers announce `grasp://` clone URLs but the transport is git + // smart HTTP, so the scheme is rewritten for gix. + pub(crate) fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()) } - names.sort(); - Ok(names) } -/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. -pub fn repo_tags(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of local branches, `refs/heads/*`, sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&gix::open(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached. -/// -/// Detached after checking out a tag or a commit directly. -pub fn current_branch(repo: &gix::Repository) -> Result> { - let head = repo.head()?; - let Some(name) = head.referent_name() else { - return Ok(None); - }; - Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) -} - -/// Branch, tag and HEAD refs of a repository. -/// -/// Ready for a NIP-34 kind-30618 repository state announcement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { - /// `(full refname, commit id)` pairs for heads and tags, sorted. pub refs: Vec<(String, String)>, - /// Short branch name HEAD points to, or `None` when detached. pub head: Option, } - -pub fn repo_ref_state(repo: &gix::Repository) -> Result { - let mut refs = Vec::new(); - - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - refs.sort(); - - let head = match repo.head() { - Ok(head) => head - .referent_name() - .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) - .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), - Err(_) => None, - }; - - Ok(RepoRefState { refs, head }) -} - -pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&gix::open(workdir)?) -} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 6e64fc0..7ef09a2 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -2,20 +2,18 @@ use std::path::{Path, PathBuf}; use ignore::WalkBuilder; -use crate::nip34::{Nip34Binding, detect_nip34}; +use crate::nip34::Nip34Binding; +use crate::repo::Repo; -/// Caps nesting so pathological trees can't stall the scan. +// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; -/// A git repository discovered under a scan root. #[derive(Debug, Clone)] pub struct LocalRepo { pub path: PathBuf, - /// `None` for a plain repository. pub nip34: Option, } -/// Walk `root` recursively and collect the git repositories below it. pub fn find_git_repos(root: &Path) -> Vec { if !root.is_dir() { return Vec::new(); @@ -49,7 +47,7 @@ pub fn find_git_repos(root: &Path) -> Vec { roots .into_iter() .map(|path| { - let nip34 = detect_nip34(&path); + let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding()); LocalRepo { path, nip34 } }) .collect() diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 6f37456..fcb5f18 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; @@ -6,55 +5,10 @@ use super::*; #[test] fn blocks_parent_components() { - assert_eq!(sanitize_path_component(".."), "_"); - assert_eq!(sanitize_path_component("."), "_"); - // Separators are neutralized before the check, so these stay safe. - assert_eq!(sanitize_path_component("../.."), ".._.."); - assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); -} - -#[test] -fn find_git_repos_discovers_repositories_recursively() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - - // Repositories are found at any depth. - // A linked worktree, with a `.git` file instead of a directory, counts too. - let nested = root.join("a/b/project"); - std::fs::create_dir_all(nested.join(".git")).unwrap(); - let worktree = root.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - "gitdir: ../a/b/project/.git/worktrees/wt", - ) - .unwrap(); - - std::fs::create_dir_all(root.join("plain")).unwrap(); - - std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); - std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - - std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - - // A repository inside another, like a submodule worktree, is not reported. - let outer = root.join("outer"); - std::fs::create_dir_all(outer.join(".git")).unwrap(); - std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); - - let mut found: Vec = find_git_repos(root) - .into_iter() - .map(|repo| repo.path) - .collect(); - found.sort(); - - let mut expected = vec![ - nested.canonicalize().unwrap(), - worktree.canonicalize().unwrap(), - outer.canonicalize().unwrap(), - ]; - expected.sort(); - assert_eq!(found, expected); + assert_eq!(GitCache::sanitize_path_component(".."), "_"); + assert_eq!(GitCache::sanitize_path_component("."), "_"); + assert_eq!(GitCache::sanitize_path_component("../.."), ".._.."); + assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b"); } #[test] @@ -63,31 +17,21 @@ fn root_commit_reports_the_first_ancestor() { commit_all(&repo, "initial"); let dir = dir.path(); - let root = root_commit(dir).expect("root").expect("commit"); + let root = repo.root_commit().expect("root").expect("commit"); assert_eq!(root.len(), 40); - // The root commit does not change when history grows. std::fs::write(dir.join("b.txt"), b"two").expect("write"); commit_all(&repo, "second"); assert_eq!( - root_commit(dir).expect("root").as_deref(), + repo.root_commit().expect("root").as_deref(), Some(root.as_str()) ); } #[test] fn push_all_mirrors_branches_and_tags() { - // A bare server repository reachable via a `file://` URL. - // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); + let server_repo = bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); @@ -100,7 +44,10 @@ fn push_all_mirrors_branches_and_tags() { git_run(dir, &["tag", "v1.0"]); let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + Repo::open(dir) + .expect("open") + .push_all(&base_url, "npub1test", "my-repo") + .expect("push"); let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); assert!(refs.contains("refs/heads/main")); @@ -111,49 +58,37 @@ fn push_all_mirrors_branches_and_tags() { #[test] fn remote_has_refs_reports_whether_pushed_refs_landed() { let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - + bare_server(server.path(), "npub1test", "my-repo"); let (dir, repo) = fixture(&[("a.txt", b"one")]); commit_all(&repo, "initial"); let dir = dir.path(); let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); let expected = vec![("refs/heads/main".to_owned(), main.clone())]; + let repo = Repo::open(dir).expect("open"); - assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(!repo.remote_has_refs(&url, &expected).expect("probe")); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); - // A stale expectation - the exact race a retry resolves - is false. let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; - assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); + assert!(!repo.remote_has_refs(&url, &stale).expect("probe")); - // Extra remote refs (e.g. a tag pushed later) do not invalidate the - // refs this push wanted to land. git_run(dir, &["tag", "v1.0"]); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); } #[test] @@ -162,9 +97,9 @@ fn repo_ref_state_lists_branches_tags_and_head() { commit_all(&repo, "initial"); let workdir = repo.workdir().expect("workdir").to_path_buf(); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); - let branch = current_branch(&repo).expect("branch").expect("on a branch"); + let branch = repo.current_branch().expect("on a branch"); assert_eq!(state.head.as_deref(), Some(branch.as_str())); assert_eq!(state.refs.len(), 1); assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); @@ -173,7 +108,7 @@ fn repo_ref_state_lists_branches_tags_and_head() { git_run(&workdir, &["branch", "feature"]); git_run(&workdir, &["tag", "v1.0"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); let mut expected: Vec = vec![ format!("refs/heads/{branch}"), "refs/heads/feature".to_owned(), @@ -190,15 +125,14 @@ fn repo_ref_state_lists_branches_tags_and_head() { ); git_run(&workdir, &["checkout", "--detach"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); assert!(state.head.is_none()); assert_eq!(state.refs.len(), 3); } -/// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. -fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { +fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { let dir = tempfile::tempdir().expect("tempdir"); - let repo = gix::init(&dir).expect("init"); + gix::init(&dir).expect("init"); for (rel, bytes) in files { let path = dir.path().join(rel); @@ -206,12 +140,11 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { std::fs::write(&path, bytes).expect("write"); } + let repo = Repo::open(dir.path()).expect("open"); (dir, repo) } -/// Stage everything and create a commit with the git CLI. -/// Like [`apply_patch`], the crate already shells out to the CLI. -fn commit_all(repo: &gix::Repository, message: &str) { +fn commit_all(repo: &Repo, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); } @@ -220,53 +153,52 @@ fn commit_all(repo: &gix::Repository, message: &str) { fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); - // A feature branch and a mainline commit diverge from the initial commit. - // The initial commit is their merge base. git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); + commit_all(&repo, "feature commit"); git_run(&path, &["checkout", "main"]); std::fs::write(path.join("main.txt"), "main\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "mainline commit"); + commit_all(&repo, "mainline commit"); assert_eq!( - merge_base(&path, "feature", "main") + repo.merge_base("feature", "main") .expect("merge base") .as_deref(), Some(initial.as_str()) ); - // An orphan branch shares no history with main, so `Ok(None)`. git_run(&path, &["checkout", "--orphan", "orphan"]); std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "orphan commit"); - assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); + commit_all(&repo, "orphan commit"); + assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None); - // An unresolvable revision is an error, not a missing ancestor. - assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); + assert!(repo.merge_base("orphan", "no-such-ref").is_err()); } #[test] fn split_patch_series_splits_real_multi_commit_mboxes() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); + commit_all(&repo, "first commit"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); + commit_all(&repo, "second commit"); - let series = format_patch_between(&path, &initial, "feature").expect("series"); - let parts = split_patch_series(&series); + let series = repo + .format_patch_between(&initial, "feature") + .expect("series"); + let parts = PatchParser::split_patch_series(&series); assert_eq!(parts.len(), 2); assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); assert!(parts[1].contains("Subject: [PATCH 2/2] second commit")); - // Each part starts its own mbox message with its own commit id. let first = parts[0].lines().next().expect("first header"); let second = parts[1].lines().next().expect("second header"); assert!(first.starts_with("From ") && first.len() >= 45); @@ -277,141 +209,73 @@ fn split_patch_series_splits_real_multi_commit_mboxes() { fn head_commit_and_commits_since_track_applied_commits() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); + assert_eq!(repo.head().as_deref(), Some(initial.as_str())); assert_eq!( - head_commit_id(&path).expect("head").as_deref(), - Some(initial.as_str()) - ); - // No base given, `HEAD` alone. - assert_eq!( - commits_since(&path, None).expect("commits"), + repo.commits_since(None).expect("commits"), vec![initial.clone()] ); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - let first = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "first commit"); + let first = repo.head().expect("on a branch"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - let second = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "second commit"); + let second = repo.head().expect("on a branch"); - // Oldest first, like the order `git am` creates them. assert_eq!( - commits_since(&path, Some(&initial)).expect("commits"), + repo.commits_since(Some(&initial)).expect("commits"), vec![first.clone(), second.clone()] ); assert_eq!( - commits_since(&path, Some(&first)).expect("commits"), + repo.commits_since(Some(&first)).expect("commits"), vec![second] ); } -#[test] -fn init_repository_creates_main_branch_and_readme() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); - assert_eq!(commit.len(), 40); - - let repo = gix::open(&path).expect("open"); - let workdir = repo.workdir().expect("workdir"); - - assert_eq!( - std::fs::read_to_string(workdir.join("README.md")).expect("read"), - "# My Repo\n\nDoes things.\n\nCool.\n" - ); - - let branch = current_branch(&repo).expect("branch").expect("on a branch"); - assert_eq!(branch, "main"); - // [`FileCommit`] carries the short id, the full id is 40 chars. - assert_eq!( - head_commit(&repo).expect("head").expect("commit").id, - &commit[..7] - ); - - let state = repo_ref_state(&repo).expect("refs"); - assert_eq!(state.head.as_deref(), Some("main")); - assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); - - // The index matches the committed tree, so the fresh repo is clean. - assert!(!worktree_dirty(workdir)); -} - -#[test] -fn set_origin_creates_or_replaces_the_remote() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - init_repository(&path, "My Repo", "").expect("init"); - - set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://gitnostr.com/npub1test/repo.git") - ); - - // An existing origin is replaced, not duplicated. - // A clone's origin points at the cloned-from path. - // It is re-targeted at the grasp server. - set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); - assert_eq!( - origin_url(&path).expect("url").as_deref(), - Some("https://grasp.example/npub1test/repo.git") - ); -} - #[test] fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { - // The mirror is a freshly initialized repository, standing in for - // the grasp server. Its `origin` points at the (fake) grasp server. - // `GitCache::ensure_clone` lazily clones from a URL shaped like this - // the first time a repository is opened. let dir = tempfile::tempdir().expect("tempdir"); let mirror = dir.path().join("mirror"); - let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); - ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init"); + Repo::open(&mirror) + .expect("open") + .ensure_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("origin"); - // The working copy is cloned from the mirror. - // It then shares the announced history exactly. - // `origin` is re-pointed at the grasp server instead of the mirror path. let destination = dir.path().join("folder").join("My_Repo"); std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); - clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); - set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + Repo::open(&destination) + .expect("open") + .set_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("set origin"); + let working = Repo::open(&destination).expect("open"); assert_eq!( - origin_url(&destination).expect("url").as_deref(), + working.origin_url().expect("url").as_deref(), Some("https://gitnostr.com/npub1test/my-repo.git") ); - assert_eq!( - head_commit_id(&destination).expect("head").as_deref(), - Some(commit.as_str()) - ); + assert_eq!(working.head().as_deref(), Some(commit.as_str())); assert!(destination.join("README.md").is_file()); } #[test] fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { - // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. let dir = tempfile::tempdir().expect("tempdir"); - let base_server = dir.path().join("npub1test").join("repo.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()); + bare_server(dir.path(), "npub1test", "repo"); let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); commit_all(&work_repo, "initial"); let work = work_dir.path(); let base_url = format!("file://{}", dir.path().display()); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); - // A mirror clone, like the app's GitCache clones. let mirror = dir.path().join("mirror"); git_run( dir.path(), @@ -423,12 +287,13 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { ], ); let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + let repo = Repo::open(&mirror).expect("open"); - // The owner pushes a new commit. - // The mirror fetches it, but its local `main` and worktree stay behind. std::fs::write(work.join("new.txt"), b"new\n").expect("write"); - commit_all(&gix::open(work).expect("open"), "new commit"); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + commit_all(&work_repo, "new commit"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); git_run(&mirror, &["fetch", "origin"]); let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); assert_eq!( @@ -437,22 +302,19 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { ); assert_ne!(remote, initial); - // Fast-forwarding catches the branch and its worktree up. - // The second call has nothing left to move. - assert!(fast_forward_branches(&mirror).expect("ff")); + assert!(repo.fast_forward_branches().expect("ff")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), remote ); assert!(mirror.join("new.txt").is_file()); - assert!(!fast_forward_branches(&mirror).expect("idle")); + assert!(!repo.fast_forward_branches().expect("idle")); - // A branch with local commits of its own is never touched. git_run(&mirror, &["checkout", "-b", "wip"]); std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); - commit_all(&gix::open(&mirror).expect("open"), "local wip"); + commit_all(&repo, "local wip"); let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); - assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert!(!repo.fast_forward_branches().expect("wip skipped")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), wip @@ -463,28 +325,19 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { 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 base_server = bare_server(dir.path(), "npub1base", "base"); 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"); + upstream_repo + .push_all( + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .expect("push"); let base_url = format!("file://{}", base_server.display()); let mirror = dir.path().join("mirror"); @@ -492,9 +345,8 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { dir.path(), &["clone", "-q", &base_url, mirror.to_str().unwrap()], ); + let mirror_repo = Repo::open(&mirror).expect("open"); - // The fork server has the same initial commit. - // It also carries a feature commit on its own `feature` branch. let fork_work = dir.path().join("fork-work"); git_run( dir.path(), @@ -502,72 +354,69 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); 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"); + commit_all(&Repo::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"); + let fork_server = bare_server(dir.path(), "npub1fork", "fork"); + Repo::open(&fork_work) + .expect("open") + .push_ref( + &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"); + mirror_repo + .fetch_refs( + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .expect("fetch"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("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"), + mirror_repo + .refs_with_prefix("refs/heads/fork") + .expect("refs"), Vec::::new() ); - // The mirror can now range across both histories. - // The fork point is the shared initial commit, 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(), + mirror_repo + .merge_base( + "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"); + let patch = mirror_repo + .format_patch_between(&initial, "refs/fork/npub1fork/fork/feature") + .expect("patch"); assert!(patch.contains("Subject: [PATCH] feature commit")); assert!(patch.contains("feature.txt")); - delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); + mirror_repo + .delete_refs_with_prefix("refs/fork/npub1fork/fork") + .expect("delete"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/fork/npub1fork/fork") + .expect("refs"), Vec::::new() ); } -/// Run a git command in `dir`, asserting success. -fn git_run(dir: &Path, args: &[&str]) { - let status = Command::new("git") +fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test Author") .env("GIT_AUTHOR_EMAIL", "test@example.com") @@ -575,324 +424,20 @@ fn git_run(dir: &Path, args: &[&str]) { .env("GIT_COMMITTER_EMAIL", "test@example.com") .env("GIT_EDITOR", "true") .args(args) - .status() + .output() .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); + assert!(output.status.success(), "git {args:?} failed"); + output } -#[test] -fn worktree_last_commits_returns_most_recent_change() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - - // A commit touching another file must not be reported for a.txt. - std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); - commit_all(&repo, "add b"); - - let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - assert_eq!(commit.summary, "change a"); - assert_eq!(commit.author, "Test Author"); - assert!(!commit.id.is_empty()); - assert!(commit.time > 0); -} - -#[test] -fn all_commits_lists_every_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "second"); - std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); - commit_all(&repo, "third"); - - let list = all_commits(&repo).expect("commits"); - assert_eq!(list.total, 3); - let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); - summaries.sort(); - assert_eq!(summaries, vec!["initial", "second", "third"]); - assert!( - list.commits - .iter() - .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) +fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf { + let repo = base.join(owner).join(format!("{name}.git")); + std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir"); + git_run( + base, + &["init", "--bare", "-q", repo.to_str().expect("utf8 path")], ); -} - -#[test] -fn worktree_last_commits_reports_merge_commits() { - let (dir, repo) = fixture(&[("a.txt", b"base")]); - commit_all(&repo, "initial"); - - let run = |args: &[&str]| { - let status = Command::new("git") - .current_dir(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("spawn git"); - assert!(status.success(), "git {args:?} failed"); - }; - run(&["checkout", "-b", "feature"]); - std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); - commit_all(&repo, "feature change"); - run(&["checkout", "-"]); - // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. - run(&["merge", "--no-ff", "--no-edit", "feature"]); - - let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")]) - .expect("lookup") - .into_iter() - .next() - .expect("found") - .1; - assert_eq!( - commit.id, - repo.head_id().expect("head").shorten_or_id().to_string() - ); - assert!(commit.summary.starts_with("Merge branch")); -} - -#[test] -fn find_readme_prefers_markdown() { - let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.md".into()) - ); -} - -#[test] -fn current_branch_tracks_checkout() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .next() - .expect("default branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); - - git_run(dir, &["checkout", "-b", "feature"]); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some("feature") - ); - - git_run(dir, &["tag", "v1.0"]); - worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); - assert_eq!(current_branch(&repo).expect("branch"), None); - - worktree_checkout_branch(dir, &default).expect("checkout branch"); - assert_eq!( - current_branch(&repo).expect("branch").as_deref(), - Some(default.as_str()) - ); -} - -#[test] -fn worktree_snapshot_reflects_checked_out_ref() { - let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - std::fs::write(dir.join("README.md"), b"# feature").expect("write"); - std::fs::write(dir.join("b.txt"), b"b").expect("write"); - commit_all(&repo, "feature work"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some("feature")); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "feature work" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# feature" - ); - let entries: Vec = snapshot - .entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - assert!(entries.contains(&"b.txt".to_string())); - - let default = worktree_branches(dir) - .expect("branches") - .into_iter() - .find(|name| name != "feature") - .expect("default branch"); - worktree_checkout_branch(dir, &default).expect("checkout"); - - let snapshot = worktree_snapshot(dir).expect("snapshot"); - assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str())); - assert_eq!( - snapshot.head_commit.as_ref().expect("head commit").summary, - "initial" - ); - assert_eq!( - String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"), - "# main" - ); - assert!( - !snapshot - .entries - .iter() - .any(|p| p.to_string_lossy() == "b.txt") - ); -} - -#[test] -fn commit_diff_lists_added_modified_and_deleted_files() { - let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write"); - std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write"); - std::fs::remove_file(dir.path().join("keep.txt")).expect("remove"); - commit_all(&repo, "changes"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 3); - - let added = by_path["new.txt"]; - assert_eq!(added.status, DiffStatus::Added); - assert_eq!(added.insertions, 1); - assert_eq!(added.deletions, 0); - assert_eq!(added.hunks.len(), 1); - assert_eq!(added.hunks[0].lines.len(), 1); - assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition); - assert_eq!(added.hunks[0].lines[0].old, None); - assert_eq!(added.hunks[0].lines[0].new, Some(1)); - assert_eq!(added.hunks[0].lines[0].text, "hello"); - - let modified = by_path["mod.txt"]; - assert_eq!(modified.status, DiffStatus::Modified); - assert_eq!(modified.insertions, 1); - assert_eq!(modified.deletions, 1); - assert!(!modified.binary); - let lines = &modified.hunks[0].lines; - // One hunk with context around the single-line change. - // The removed line is old 2, the added line is new 2. - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Deletion - && line.old == Some(2) - && line.new.is_none() - && line.text == "two" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Addition - && line.old.is_none() - && line.new == Some(2) - && line.text == "two!" - })); - assert!(lines.iter().any(|line| { - line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1) - })); - - let deleted = by_path["keep.txt"]; - assert_eq!(deleted.status, DiffStatus::Deleted); - assert_eq!(deleted.deletions, 1); - assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion); - assert_eq!(deleted.hunks[0].lines[0].old, Some(1)); - assert_eq!(deleted.hunks[0].lines[0].new, None); -} - -#[test] -fn commit_range_diff_lists_changes_between_two_commits() { - let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]); - commit_all(&repo, "first"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write"); - std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write"); - commit_all(&repo, "second"); - let tip = repo.head_id().expect("head").to_string(); - - let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff"); - - let by_path: HashMap<&str, &FileDiff> = diff - .files - .iter() - .map(|file| (file.path.as_str(), file)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path["a.txt"].status, DiffStatus::Modified); - assert_eq!(by_path["a.txt"].insertions, 1); - assert_eq!(by_path["a.txt"].deletions, 1); - assert_eq!(by_path["c.txt"].status, DiffStatus::Added); - // b.txt is unchanged between the two commits. - assert!(diff.files.iter().all(|file| file.path != "b.txt")); -} - -#[test] -fn commit_diff_reports_binary_files_without_hunks() { - let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); - commit_all(&repo, "initial"); - - std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write"); - commit_all(&repo, "binary change"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "blob.bin") - .expect("file"); - assert!(file.binary); - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); -} - -#[test] -fn commit_diff_reports_renames() { - let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); - commit_all(&repo, "initial"); - - std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename"); - commit_all(&repo, "rename"); - - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(_dir.path(), &head).expect("diff"); - let file = diff - .files - .iter() - .find(|f| f.path == "new.txt") - .expect("file"); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.old_path.as_deref(), Some("old.txt")); - // A pure rename has no content change, the file is still listed. - assert!(file.hunks.is_empty()); - assert_eq!(file.insertions, 0); - assert_eq!(file.deletions, 0); + repo } #[test] @@ -928,7 +473,7 @@ diff --git a/b.txt b/b.txt +y "#; - let commits = patch_commits(patch); + let commits = PatchParser::patch_commits(patch); assert_eq!(commits.len(), 2); assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); @@ -944,10 +489,6 @@ diff --git a/b.txt b/b.txt #[test] fn parses_real_format_patch_output() { - // Build a commit touching a mix of file kinds. - // Feed genuine `git format-patch` output through the parser. - // It covers quoted and octal-escaped paths. - // There are also a rename-free modification, an addition and a binary deletion. let (dir, repo) = fixture(&[ ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), ("my file.txt", b"hello\n"), @@ -971,19 +512,10 @@ fn parses_real_format_patch_output() { std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write"); commit_all(&repo, "changes"); - let output = Command::new("git") - .current_dir(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") - .args(["format-patch", "-1", "--stdout"]) - .output() - .expect("spawn git format-patch"); - assert!(output.status.success(), "git format-patch failed"); + let output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]); let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); - let diff = patch_diffs(&patch).expect("parse real format-patch output"); + let diff = PatchParser::patch_diffs(&patch).expect("parse real format-patch output"); let by_path = |path: &str| { diff.files @@ -992,12 +524,10 @@ fn parses_real_format_patch_output() { .unwrap_or_else(|| panic!("missing file {path:?}")) }; - // Space in the name makes git quote the path in the header. let file = by_path("my file.txt"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); - // UTF-8 names are emitted as octal escapes. let file = by_path("\u{8bf4}\u{660e}.md"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); @@ -1012,54 +542,10 @@ fn parses_real_format_patch_output() { assert_eq!(file.status, DiffStatus::Added); assert_eq!(file.insertions, 1); - // A binary deletion emits no `---` or `+++` lines. - // Only the mode line and the `Binary files` marker remain. let file = by_path("img.png"); assert_eq!(file.status, DiffStatus::Deleted); assert!(file.binary); assert!(file.hunks.is_empty()); -} - -#[test] -fn worktree_dirty_tracks_changes_and_untracked_files() { - let (dir, repo) = fixture(&[("tracked.txt", b"one")]); - commit_all(&repo, "initial"); - let workdir = dir.path(); - - assert!(!worktree_dirty(workdir)); - - std::fs::write(workdir.join("tracked.txt"), b"two").expect("write"); - assert!(worktree_dirty(workdir)); - - // After restoring, an untracked file alone is dirty as well. - git_run(workdir, &["checkout", "--", "tracked.txt"]); - assert!(!worktree_dirty(workdir)); - std::fs::write(workdir.join("untracked.txt"), b"new").expect("write"); - assert!(worktree_dirty(workdir)); - - git_run(workdir, &["rm", "--cached", "tracked.txt"]); - assert!(worktree_dirty(workdir)); - - // A missing directory is clean, not an error. - assert!(!worktree_dirty(&dir.path().join("missing"))); -} - -#[test] -fn worktree_commits_ahead_counts_branch_only_commits() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let path = dir.path(); - - git_run(path, &["checkout", "-q", "-b", "feature"]); - std::fs::write(path.join("f.txt"), b"f\n").expect("write"); - commit_all(&gix::open(path).expect("open"), "feature work"); - - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); - assert_eq!(worktree_commits_ahead(path, "feature", "main"), 0); - - git_run(path, &["checkout", "-q", "main"]); - assert_eq!(worktree_current_branch(path).as_deref(), Some("main")); - assert!(worktree_ref_exists(path, "refs/heads/feature")); - assert!(!worktree_ref_exists(path, "refs/heads/nope")); - assert_eq!(worktree_commits_ahead(path, "main", "feature"), 1); + assert_eq!(file.insertions, 0); + assert_eq!(file.deletions, 0); } diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index dd68418..3e3a350 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -4,331 +4,311 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use gix::progress::Discard; -use crate::history::{FileCommit, head_commit}; -use crate::repo::{current_branch, repository_signature}; +use crate::history::FileCommit; +use crate::repo::Repo; -/// Whether the worktree of `workdir` has uncommitted changes. -/// -/// Best-effort: any read failure is reported as clean. -pub fn worktree_dirty(workdir: &Path) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - - // Changes to tracked files, staged or not; untracked files are excluded. - match repo.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. - let Ok(platform) = repo.status(Discard) else { - return false; - }; - - let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) - else { - return false; - }; - - for change in changes.by_ref() { - match change { - Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, - Ok(_) => {} +impl Repo { + // Best-effort: any read failure is reported as clean. + pub fn is_dirty(&self) -> bool { + // Tracked files, staged or not; untracked files are handled below. + match self.inner.is_dirty() { + Ok(true) => return true, + Ok(false) => {} Err(_) => return false, } - } - false -} + // Untracked files surface as `DirectoryContents` items of the + // index-vs-worktree walk; tracked files only appear there when modified. + let Ok(platform) = self.inner.status(Discard) else { + return false; + }; -/// Commits in `base..branch` of the checkout at `workdir`. -/// -/// Best-effort: 0 when the range cannot be computed. -pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { - let Ok(repo) = gix::open(workdir) else { - return 0; - }; + let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) + else { + return false; + }; - let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) - else { - return 0; - }; - - let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 -} - -/// Resolve `rev` to a commit id, accepting full refs or the bare branch names -/// callers pass. `gix`'s revision parser already applies git's ref DWIM. -fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { - repo.rev_parse_single(rev.as_bytes()).ok() -} - -/// Relative paths of all entries in the worktree, files and directories. -/// -/// The `.git` directory is skipped. -pub fn worktree_entries(repo: &gix::Repository) -> Result> { - let workdir = repo.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - collect_entries(workdir, workdir, &mut entries)?; - - entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { - b_is_dir - .cmp(a_is_dir) - .then_with(|| a.as_os_str().cmp(b.as_os_str())) - }); - Ok(entries.into_iter().map(|(path, _)| path).collect()) -} - -/// Read a file from the worktree. -/// -/// Returns `Ok(None)` if the path is missing or not a regular file. -pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { - let workdir = repo.workdir().context("repository has no worktree")?; - let path = workdir.join(rel); - - match std::fs::read(&path) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), - Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), - } -} - -/// Find the README file in the repository root. -/// -/// Falls back to any other file whose name starts with `readme`. -pub fn find_readme(repo: &gix::Repository) -> Result> { - let Some(workdir) = repo.workdir() else { - return Ok(None); - }; - - let mut candidates: Vec = Vec::new(); - for entry in std::fs::read_dir(workdir)? { - let entry = entry?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.to_ascii_lowercase().starts_with("readme") { - candidates.push(entry.path()); - } - } - - candidates.sort_by_key(|path| { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("md") => 0, - Some("markdown") => 1, - Some("mdown") => 2, - Some("mkdn") => 3, - Some(_) => 5, - None => 4, - } - }); - - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) -} - -/// Everything the browser needs to refresh after a branch or tag switch. -pub struct WorktreeSnapshot { - /// Relative paths of all worktree entries, directories first. - pub entries: Vec, - /// README path relative to the worktree, if any. - pub readme_path: Option, - /// Contents of the README, if any. - pub readme: Option>, - /// Branch HEAD points to, `None` when detached, for example on a tag. - pub current_branch: Option, - /// Commit HEAD points to, if any, see [`head_commit`]. - pub head_commit: Option, -} - -/// Collects entries, the README, the branch HEAD points to and its commit. -pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let readme_path = find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => worktree_read(&repo, path)?, - None => None, - }; - Ok(WorktreeSnapshot { - entries: worktree_entries(&repo)?, - readme_path, - readme, - current_branch: current_branch(&repo)?, - head_commit: head_commit(&repo)?, - }) -} - -pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { - let workdir = repo - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = repo.index_from_tree(tree)?; - - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. - if let Ok(previous) = repo.index_or_empty() { - let keep: HashSet = index - .entries() - .iter() - .map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())) - .collect(); - for entry in previous.entries() { - let rel = entry.path(&previous); - let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); - - if keep.contains(&rel) { - continue; + for change in changes.by_ref() { + match change { + Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, + Ok(_) => {} + Err(_) => return false, } + } - let path = workdir.join(&rel); + false + } - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", path.display())); + // Best-effort: 0 when the range cannot be computed. + pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { + let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) + else { + return 0; + }; + + let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; + + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 + } + + // Accepts full refs or the bare branch names callers pass; `gix`'s + // revision parser already applies git's ref DWIM. + fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { + self.inner.rev_parse_single(rev.as_bytes()).ok() + } + + // The `.git` directory is skipped. + pub fn entries(&self) -> Result> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + Self::collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) + } + + // `Ok(None)` when the path is missing or not a regular file. + pub fn read(&self, rel: &Path) -> Result>> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } + } + + // Falls back to any other file whose name starts with `readme`. + pub fn find_readme(&self) -> Result> { + let Some(workdir) = self.inner.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) + } + + pub fn snapshot(&self) -> Result { + let readme_path = self.find_readme()?; + let readme = match &readme_path { + Some(path) => self.read(path)?, + None => None, + }; + Ok(WorktreeSnapshot { + entries: self.entries()?, + readme_path, + readme, + current_branch: self.current_branch(), + head_commit: self.head_commit().unwrap_or(None), + branches: self.branches().unwrap_or_default(), + tags: self.tags().unwrap_or_default(), + }) + } + + // HEAD stays attached to the branch. + pub fn checkout_branch(&self, name: &str) -> Result<()> { + let full = format!("refs/heads/{name}"); + + let branch = gix::refs::FullName::try_from(full.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let mut reference = self.inner.find_reference(&full)?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Symbolic(branch), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + // HEAD becomes detached at the tagged commit. + pub fn checkout_tag(&self, name: &str) -> Result<()> { + let full = format!("refs/tags/{name}"); + + let mut reference = self.inner.find_reference(&full)?; + + let commit = reference.peel_to_id()?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Object(commit.detach()), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); + + let mut index = self.inner.index_from_tree(tree)?; + + // Files the previous index tracked but `tree` no longer contains are + // removed, like git deleting files that vanish between branches. + if let Ok(previous) = self.inner.index_or_empty() { + let keep: HashSet = index + .entries() + .iter() + .map(|entry| { + PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()) + }) + .collect(); + for entry in previous.entries() { + let rel = entry.path(&previous); + let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); + + if keep.contains(&rel) { + continue; + } + + let path = workdir.join(&rel); + + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", path.display())); + } } } } + + let mut options = self + .inner + .checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; + options.overwrite_existing = true; + + let objects = self.inner.objects.clone().into_arc()?; + let files = gix::progress::Discard; + let bytes = gix::progress::Discard; + + gix_worktree_state::checkout( + &mut index, + workdir, + objects, + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + options, + )?; + + index.write(gix::index::write::Options::default())?; + + Ok(()) } - let mut options = - repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; + fn move_head( + &self, + signature: gix::actor::SignatureRef<'_>, + target: gix::refs::Target, + message: &str, + ) -> Result<()> { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - let objects = repo.objects.clone().into_arc()?; - let files = gix::progress::Discard; - let bytes = gix::progress::Discard; + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - gix_worktree_state::checkout( - &mut index, - workdir, - objects, - &files, - &bytes, - &gix::interrupt::IS_INTERRUPTED, - options, - )?; - - index.write(gix::index::write::Options::default())?; - - Ok(()) -} - -/// Point `HEAD` at `target` and record the switch in the reflog. -fn move_head( - repo: &gix::Repository, - signature: gix::actor::SignatureRef<'_>, - target: gix::refs::Target, - message: &str, -) -> Result<()> { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.into(), + self.inner.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: target, }, - expected: PreviousValue::Any, - new: target, - }, - name: head, - deref: false, - }], - Some(signature), - )?; + name: head, + deref: false, + }], + Some(signature), + )?; - Ok(()) -} - -/// Check out the local branch `name`, HEAD stays attached to it. -pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/heads/{name}"); - - let branch = gix::refs::FullName::try_from(full.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let mut reference = repo.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - Ok(()) -} - -/// Check out the tag `name`, HEAD becomes detached at the tagged commit. -pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/tags/{name}"); - - let mut reference = repo.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - Ok(()) -} - -fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; - } - - let is_dir = entry.file_type()?.is_dir(); - let path = entry.path(); - let rel = path.strip_prefix(root)?.to_path_buf(); - out.push((rel, is_dir)); - - if is_dir { - collect_entries(root, &path, out)?; + Ok(()) + } + + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + Self::collect_entries(root, &path, out)?; + } } + Ok(()) } - Ok(()) +} + +pub struct WorktreeSnapshot { + pub entries: Vec, + pub readme_path: Option, + pub readme: Option>, + // `None` when detached, for example on a tag. + pub current_branch: Option, + pub head_commit: Option, + pub branches: Vec, + pub tags: Vec, } diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml index 5308c8e..dc6b3f3 100644 --- a/crates/signed_nostr/Cargo.toml +++ b/crates/signed_nostr/Cargo.toml @@ -8,12 +8,7 @@ publish.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true nostr-gossip-memory.workspace = true +nostr-lmdb.workspace = true anyhow.workspace = true webbrowser.workspace = true - -[target.'cfg(target_arch = "wasm32")'.dependencies] -nostr-memory.workspace = true - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -nostr-lmdb.workspace = true diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index ee66bfa..1ab564c 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -1,51 +1,47 @@ -#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::time::Duration; use anyhow::{Context, Result}; use nostr_gossip_memory::prelude::*; -#[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; -#[cfg(target_arch = "wasm32")] -use nostr_memory::prelude::*; use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -#[cfg(not(target_arch = "wasm32"))] -pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { - let signer = UniversalSigner::new(Keys::generate()); - let database = NostrLmdb::open(db_path) - .await - .context("failed to open nostr database")?; - Ok(with_database(signer, database)) +pub struct NostrBackend { + pub client: Client, + pub signer: UniversalSigner, } -/// In-memory database on wasm, LMDB is unavailable there. -#[cfg(target_arch = "wasm32")] -pub fn new_backend() -> Result<(Client, UniversalSigner)> { - let signer = UniversalSigner::new(Keys::generate()); - Ok(with_database(signer, MemoryDatabase::unbounded())) -} +impl NostrBackend { + pub async fn open(db_path: impl AsRef) -> Result { + let db_path = db_path.as_ref(); + let signer = UniversalSigner::new(Keys::generate()); + let database = NostrLmdb::open(db_path) + .await + .context("failed to open nostr database")?; + Ok(Self::with_database(signer, database)) + } -fn with_database(signer: UniversalSigner, database: D) -> (Client, UniversalSigner) -where - D: IntoNostrDatabase, -{ - let authenticator = SignerAuthenticator::new(signer.clone()); + fn with_database(signer: UniversalSigner, database: D) -> Self + where + D: IntoNostrDatabase, + { + let authenticator = SignerAuthenticator::new(signer.clone()); - let client = ClientBuilder::default() - .database(database) - .authenticator(authenticator) - .gossip(NostrGossipMemory::unbounded()) - .gossip_config(GossipConfig::default().no_background_refresh()) - .connect_timeout(Duration::from_secs(10)) - .verify_subscriptions(true) - .ban_relay_on_mismatch(true) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }) - .build(); + let client = ClientBuilder::default() + .database(database) + .authenticator(authenticator) + .gossip(NostrGossipMemory::unbounded()) + .gossip_config(GossipConfig::default().no_background_refresh()) + .connect_timeout(Duration::from_secs(10)) + .verify_subscriptions(true) + .ban_relay_on_mismatch(true) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }) + .build(); - (client, signer) + Self { client, signer } + } } diff --git a/crates/signed_nostr/src/lib.rs b/crates/signed_nostr/src/lib.rs index 3c99075..1328bf4 100644 --- a/crates/signed_nostr/src/lib.rs +++ b/crates/signed_nostr/src/lib.rs @@ -2,6 +2,6 @@ mod backend; mod signer; mod update; -pub use backend::new_backend; +pub use backend::NostrBackend; pub use signer::{SignedAuthUrlHandler, UniversalSigner}; pub use update::Update; diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs index 8ebf43e..601a286 100644 --- a/crates/signed_nostr/src/signer.rs +++ b/crates/signed_nostr/src/signer.rs @@ -8,7 +8,6 @@ use nostr_connect::client::AuthUrlHandler; use nostr_sdk::error::Error as SignerError; use nostr_sdk::prelude::*; -/// A type-erased signer whose inner signer can be swapped in-place. #[derive(Clone, Debug)] pub struct UniversalSigner { inner: Arc>>, @@ -27,7 +26,6 @@ impl UniversalSigner { } } - /// Swap the inner signer in-place. All clones see the new signer. pub fn swap_inner(&self, new_signer: T) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, @@ -160,7 +158,6 @@ impl AsyncNip44 for UniversalSigner { } } -/// Opens the NIP-46 auth URL in the default browser. #[derive(Debug, Clone)] pub struct SignedAuthUrlHandler; diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index a7d1998..277f6c3 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -1,10 +1,8 @@ use nostr_sdk::prelude::*; -/// A lightweight change notification for the UI. #[derive(Debug, Clone)] pub struct Update { pub kind: Kind, - /// First `a` tag value of the event, if any, for example the repository coordinate. pub coordinate: Option, pub author: PublicKey, } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 6ef045e..938a268 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,49 +1,38 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, Instant}; use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; -use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Task}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, WeakEntity}; use nostr::event::IntoEventBuilder; use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; -use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; +use signed_core::{Announcement, Filters, RepoAddr, filters}; +use signed_git::{GitCache, Repo}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; -use crate::git_store::repo_mirror_path; +use crate::bootstrap::{subscribe_bootstrap_only, sync_bootstrap_only, user_grasp_list_servers}; +use crate::git_store::Mirrors; use crate::inbox::Inbox; +use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url}; use crate::repos::RepoListStore; pub const USER_KEYRING: &str = "Signed Safe Storage"; -/// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; -/// Relays connected at startup, before any user-specific relay config is known. -pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; -/// Relays used to index the user's NIP-65 relay list. -pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] pub enum BackendEvent { SignerRequired, - /// The stored identity is NIP-49 encrypted key. PassphraseRequired, - /// The signer changed on login, logout or account switch. SignerChanged, - /// Kind-0 metadata arrived for these authors; re-read them from the store. ProfileUpdates(Vec), - /// Repository events arrived: announcements, states, activity and deletions. RepoUpdates(Vec), Synced, - SyncProgress { - total: u64, - current: u64, - }, Error(String), } @@ -61,8 +50,6 @@ pub struct Backend { signer: UniversalSigner, current_user: Option, inbox: Entity, - sync_progress: Option<(u64, u64)>, - /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, pushing_repos: Entity>, } @@ -93,7 +80,7 @@ impl Backend { let mut seen: HashSet = HashSet::new(); 'outer: loop { - match next_update(&mut notifications, &mut seen).await { + match UpdateEvent::next(&mut notifications, &mut seen).await { Some(UpdateEvent::Profile(author)) => { pending_profiles.insert(author); } @@ -113,7 +100,7 @@ impl Backend { let timer = cx.background_executor().timer(deadline - now); futures::pin_mut!(timer); - let next = next_update(&mut notifications, &mut seen); + let next = UpdateEvent::next(&mut notifications, &mut seen); futures::pin_mut!(next); match futures::future::select(next, timer).await { @@ -160,17 +147,12 @@ impl Backend { signer, current_user: None, inbox: cx.new(|_| Inbox::default()), - sync_progress: None, passphrase_required: false, pushing_repos: cx.new(|_| HashSet::new()), } } - /// Restore the saved session from the Keyring. - /// - /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. - /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. - pub fn restore_session(&mut self, cx: &mut Context) { + fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); return; @@ -234,7 +216,6 @@ impl Backend { task.detach(); } - /// Decrypt the NIP-49 keyring credential with the given passphrase. pub fn restore_with_passphrase( &mut self, password: &str, @@ -303,7 +284,6 @@ impl Backend { write.await?; this.update(cx, |this, cx| { - // Become the new identity so later publishes are signed with the new keys. this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); @@ -343,20 +323,16 @@ impl Backend { .map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect(); - let client = this.client.clone(); - let signer = this.signer.clone(); + let pusher = GraspPush::new(this.client.clone(), this.signer.clone()); for builder in [ RelayList::new(relays).into_event_builder(), metadata, GitUserGraspList { grasp_servers }.into_event_builder(), ] { - let client = client.clone(); - let signer = signer.clone(); - cx.spawn(async move |_this, _cx| { - publish_best_effort(&client, &signer, builder).await - }) - .detach(); + let pusher = pusher.clone(); + cx.spawn(async move |_this, _cx| pusher.publish_best_effort(builder).await) + .detach(); } })?; @@ -364,9 +340,6 @@ impl Backend { }) } - /// Initialize a local clone with a `main` branch and a `README.md`. - /// - /// The task yields the announcement and the path of the working copy. pub fn create_repository( &mut self, name: &str, @@ -394,7 +367,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -412,9 +385,8 @@ impl Backend { let servers = grasp_servers.clone(); let client = self.client.clone(); - // Initialize directly at the user's chosen destination. let destination = { - let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = GitCache::sanitize_path_component(&name); let dir_name = if dir_name.is_empty() { "repository".to_owned() } else { @@ -437,11 +409,11 @@ impl Backend { bail!("destination {} already exists", destination.display()); } - let commit = signed_git::init_repository(&destination, &name, &description)?; + let commit = Repo::init(&destination, &name, &description)?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); - signed_git::set_origin(&destination, &url)?; + Repo::open(&destination)?.set_origin(&url)?; } Ok::<_, Error>(commit) @@ -451,92 +423,32 @@ impl Backend { let commit = work.await?; let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; - // The nostr client queues events until each relay is connected. - for url in &servers { - client.add_relay(url).and_connect().await.ok(); - } - - // The state event is the push authorization. It must be accepted before the push below. - let announcement = GitRepositoryAnnouncement { - id: repo_id.clone(), - name: Some(name.clone()), - description: (!description.is_empty()).then_some(description.clone()), - web: Vec::new(), - clone: servers - .iter() - .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) - .collect(), - relays: servers.clone(), - euc: Some(commit_sha), - maintainers: Vec::new(), - }; - let signer = this.update(cx, |this, _cx| this.signer.clone())?; - let event = { - let builder = announcement.into_event_builder(); - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)? - }; + let announcement = repository_announcement( + &repo_id, + &name, + &description, + &owner, + &servers, + Some(commit_sha), + ); - // The state event is the push authorization. Stage it on each - // grasp server's relay, then push the initial commit. - // Creation fails only when no server accepted the push, the announcement - // is then retracted so the repository is not left announced without content. - let refs = vec![("refs/heads/main".to_owned(), commit)]; - - let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); - let destination = destination.clone(); - let owner = owner.clone(); - let repo_id = repo_id.clone(); - let servers = servers.clone(); - let refs = refs.clone(); - let executor = cx.background_executor().clone(); - async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - Some("main"), - &destination, - &owner, - &servers, - &executor, - signed_git::push_main, - ) - .await - } - }); - - let outcome = push.await; - - if outcome.accepted() == 0 { - // The announcement is already published. - // Retract it so the repository is not left announced without content. - this.update(cx, |this, cx| { - this.retract_events(std::slice::from_ref(&event), cx); - }) - .ok(); - - return Err(anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - The announcement has been retracted", - outcome.failure_summary() - )); - } - - // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing makes it - // visible to the other relays and clients. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await - { - log::warn!("failed to broadcast repository state: {e}"); - } + let event = announce_repository_and_push( + &this, + &client, + &signer, + announcement, + &repo_id, + &owner, + &servers, + vec![("refs/heads/main".to_owned(), commit)], + Some("main".to_owned()), + &destination, + cx, + |path, base, owner, repo_id| Repo::open(path)?.push_main(base, owner, repo_id), + ) + .await?; let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; @@ -545,7 +457,6 @@ impl Backend { }) } - /// Publish an existing local repository to NIP-34. pub fn publish_local_repo( &mut self, path: PathBuf, @@ -569,7 +480,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to publish a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -591,121 +502,57 @@ impl Backend { let work = cx.background_spawn({ let path = path.clone(); async move { - let state = signed_git::worktree_ref_state(&path)?; - let euc = signed_git::root_commit(&path)?; + let repo = Repo::open(&path)?; + let state = repo.ref_state()?; + let euc = repo.root_commit()?; Ok::<_, Error>((state, euc)) } }); let (state, euc) = work.await?; - // The nostr client queues events until each relay is connected. - for url in &servers { - client.add_relay(url).and_connect().await.ok(); - } - - // The state event is the push authorization. It must be accepted before the push below. - let announcement = GitRepositoryAnnouncement { - id: repo_id.clone(), - name: Some(name.clone()), - description: (!description.is_empty()).then_some(description.clone()), - web: Vec::new(), - clone: servers - .iter() - .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) - .collect(), - relays: servers.clone(), - euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()), - maintainers: Vec::new(), - }; - let signer = this.update(cx, |this, _cx| this.signer.clone())?; - let event = { - let builder = announcement.into_event_builder(); - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)? - }; + let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()); + let announcement = + repository_announcement(&repo_id, &name, &description, &owner, &servers, euc); - let refs = state.refs.clone(); - let head = state.head.clone(); + let event = announce_repository_and_push( + &this, + &client, + &signer, + announcement, + &repo_id, + &owner, + &servers, + state.refs.clone(), + state.head.clone(), + &path, + cx, + |path, base, owner, repo_id| Repo::open(path)?.push_all(base, owner, repo_id), + ) + .await?; - // The state event is the push authorization. Stage it on each - // grasp server's relay, then push every branch and tag. The push - // fails only when no server accepted it. The announcement is then - // retracted so the repository is not left announced without content. - // An empty repository has no state to stage and nothing to push. - if !refs.is_empty() { - let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); - let path = path.clone(); - let owner = owner.clone(); - let repo_id = repo_id.clone(); - let servers = servers.clone(); - let refs = refs.clone(); - let head = head.clone(); - let executor = cx.background_executor().clone(); - async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &servers, - &executor, - signed_git::push_all, - ) - .await - } - }); - let outcome = push.await; - - if outcome.accepted() == 0 { - // The announcement is already published. Retract it so - // the repository is not left announced without content. - this.update(cx, |this, cx| { - this.retract_events(std::slice::from_ref(&event), cx); - }) - .ok(); - - return Err(anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - The announcement has been retracted", - outcome.failure_summary() - )); - } - - // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing makes it visible to the other relays and clients. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await - { - log::warn!("failed to broadcast repository state: {e}"); - } - } - - // Point `origin` at the first grasp server so later pushes have a target. if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); let path = path.clone(); cx.background_spawn(async move { - signed_git::ensure_origin(&path, &url).ok(); + Repo::open(&path) + .and_then(|repo| repo.ensure_origin(&url)) + .ok(); }) .await; } - // Record the ngit-compatible `nostr.repo` marker, - // so the next scan detects the repository instead of offering to publish it again. - let coordinate = repo_addr(event.pubkey, repo_id.clone()); - match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() { + // The ngit-compatible `nostr.repo` marker makes the next scan + // detect the repository instead of offering to publish it again. + let coordinate = RepoAddr::new(event.pubkey, repo_id.clone()); + match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() { Ok(naddr) => { let path = path.clone(); cx.background_spawn(async move { - if let Err(error) = signed_git::set_nostr_repo(&path, &naddr) { + if let Err(error) = + Repo::open(&path).and_then(|repo| repo.set_nostr_repo(&naddr)) + { log::warn!( "failed to record the NIP-34 marker for {}: {error}", path.display() @@ -721,23 +568,17 @@ impl Backend { }) } - /// Re-push the repository's current refs to the grasp servers in its `relays` tag. - /// - /// Errors when no grasp server accepted the push, the outcome reports - /// which servers did when only some accepted it. + // Errors when no grasp server accepted the push; the outcome reports + // which servers did when only some accepted it. pub fn push_repository( &mut self, announcement: Announcement, cx: &mut Context, ) -> Task> { - let path = repo_mirror_path(&announcement.addr()); + let path = Mirrors::path(&announcement.addr()); self.push_repo_from(announcement, path, None, cx) } - /// Push the refs of a local checkout to the grasp servers in its `relays` tag. - /// The checkout is the working copy of the user's own repository. - /// - /// Publish a fresh state event, then push every branch and tag of the checkout. pub fn push_checkout( &mut self, announcement: Announcement, @@ -748,7 +589,6 @@ impl Backend { self.push_repo_from(announcement, checkout, announced_head, cx) } - /// Shared body of the mirror-based and checkout-based pushes. fn push_repo_from( &mut self, announcement: Announcement, @@ -774,8 +614,8 @@ impl Backend { let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - // Held for the whole task. Runs on completion, on error and on - // cancellation alike, since dropping the task drops this guard. + // Runs on completion, on error and on cancellation alike; the + // guard would be dropped with the task if not held. let _guard = cx.on_drop(&this, { let addr = addr.clone(); move |backend, cx| { @@ -789,15 +629,13 @@ impl Backend { let mut state = { let work = cx.background_spawn({ let path = path.clone(); - async move { signed_git::worktree_ref_state(&path) } + async move { Repo::open(&path).and_then(|repo| repo.ref_state()) } }); work.await? }; - // The state event announces the pushed refs. - // Keep the announced default branch in `HEAD` when it is among the pushed refs. - // - // Otherwise `HEAD` stays the checkout's current branch. + // Keep the announced default branch in `HEAD` when it is among + // the pushed refs; otherwise `HEAD` stays the checkout's branch. let heads: Vec<&str> = state .refs .iter() @@ -810,9 +648,8 @@ impl Backend { state.head = Some(head); } - // Grasp servers authorize a push by the state event they hold in purgatory. - // Stage the state event on each server's own relay, then push the git data, - // retrying transient purgatory denials. + // Grasp servers authorize a push by the state event they hold in + // purgatory: stage it on each server's relay before the git push. let refs = state.refs.clone(); let head = state.head.clone(); @@ -823,8 +660,7 @@ impl Backend { PushOutcome::default() } else { let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); + let pusher = GraspPush::new(client.clone(), signer.clone()); let path = path.clone(); let owner = owner.clone(); let repo_id = repo_id.clone(); @@ -833,19 +669,20 @@ impl Backend { let head = head.clone(); let executor = cx.background_executor().clone(); async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &relays, - &executor, - signed_git::push_all, - ) - .await + pusher + .push_staged_to_grasps( + &repo_id, + &refs, + head.as_deref(), + &path, + &owner, + &relays, + &executor, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, + ) + .await } }); push.await @@ -859,8 +696,6 @@ impl Backend { } // Fan the state out to the relays once a git server holds the objects. - // Staging already stored the event locally, publishing notifies - // the repository views and other relays and clients. if let Some(state_event) = &outcome.state_event && let Err(e) = client.send_event(state_event).broadcast().await { @@ -871,9 +706,6 @@ impl Backend { }) } - /// Delete the repository from nostr. - /// - /// Only the repository owner may delete it. pub fn delete_repository( &mut self, addr: RepoAddr, @@ -882,7 +714,7 @@ impl Backend { let Some(public_key) = self.current_user else { return Task::ready(Err(anyhow!("Sign in to delete a repository"))); }; - if public_key != addr.public_key { + if public_key != addr.public_key() { return Task::ready(Err(anyhow!("Only the repository owner can delete it"))); } @@ -894,9 +726,9 @@ impl Backend { let db = client.database(); let mut events = Vec::new(); for filter in [ - filters::announcement(&addr), - filters::state(&addr), - filters::activity(&addr), + addr.announcement_filter(), + addr.state_filter(), + addr.activity_filter(), ] { events.extend(db.query(filter).await?); } @@ -913,117 +745,6 @@ impl Backend { }) } - /// Login with an `nsec1...` key or a `bunker://...` URI. - pub fn login(&mut self, credential: &str, cx: &mut Context) { - let credential = credential.trim(); - - if credential.starts_with("nsec1") { - self.login_with_nsec(credential, cx); - } else if credential.starts_with("bunker://") { - self.login_with_bunker(credential, cx); - } else { - cx.emit(BackendEvent::error("Unsupported credential.")); - } - } - - pub fn login_with_new_identity(&mut self, cx: &mut Context) { - let nsec = Keys::generate() - .secret_key() - .to_bech32() - .expect("infallible"); - self.login_with_nsec(&nsec, cx); - } - - pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { - let keys = match SecretKey::parse(nsec) { - Ok(secret) => Keys::new(secret), - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let nsec = nsec.trim().to_owned(); - let pubkey = keys.public_key().to_hex(); - let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - if let Err(e) = write.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - return Ok(()); - } - this.update(cx, |this, cx| this.set_signer(keys, cx))?; - Ok(()) - }); - task.detach(); - } - - /// Login with a `bunker://...` URI, NIP-46. - pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { - let uri_string = uri.trim().to_owned(); - - let connect_uri = match NostrConnectUri::parse(&uri_string) { - Ok(uri) => uri, - Err(e) => { - cx.emit(BackendEvent::error(e.to_string())); - return; - } - }; - - let keys = Keys::generate(); - let credential = with_master_key(&uri_string, &keys); - let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); - - let task: Task> = cx.spawn(async move |this, cx| { - let result = async { - let mut signer = NostrConnect::new( - connect_uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - signer.auth_url_handler(SignedAuthUrlHandler); - - // Verify the signer before persisting the credential. - signer.get_public_key_async().await?; - write.await?; - - this.update(cx, |this, cx| this.set_signer(signer, cx))?; - - Ok::<_, Error>(()) - } - .await; - - if let Err(e) = result { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - - Ok(()) - }); - task.detach(); - } - - pub fn logout(&mut self, cx: &mut Context) { - let delete = cx.delete_credentials(USER_KEYRING); - - let task: Task> = cx.spawn(async move |this, cx| { - delete.await.ok(); - - this.update(cx, |this, cx| { - this.signer.swap_inner(Keys::generate()); - this.current_user = None; - this.passphrase_required = false; - cx.emit(BackendEvent::SignerChanged); - cx.emit(BackendEvent::SignerRequired); - this.sync_inbox(cx); - cx.notify(); - })?; - - Ok(()) - }); - task.detach(); - } - fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); @@ -1031,7 +752,7 @@ impl Backend { let result = async { sync_bootstrap_only( &client, - filters::grasp_list(public_key), + Filters::grasp_list(public_key), SyncOptions::default(), ) .await?; @@ -1078,8 +799,8 @@ impl Backend { let me = self.current_user; if let Some(me) = me { - self.subscribe_bootstrap(filters::notifications(me), cx); - self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + self.subscribe_bootstrap(Filters::notifications(me), cx); + self.subscribe_bootstrap(vec![Filters::authored_activity(me)], cx); let relays: HashSet = RepoListStore::global(cx) .read(cx) @@ -1090,8 +811,8 @@ impl Backend { if !relays.is_empty() { let relays: Vec = relays.into_iter().collect(); - self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); - self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + self.connect_repo_relays(relays.clone(), Filters::notifications(me), cx); + self.connect_repo_relays(relays, vec![Filters::authored_activity(me)], cx); } } @@ -1101,11 +822,7 @@ impl Backend { }); } - pub fn sync_progress(&self) -> Option<(u64, u64)> { - self.sync_progress - } - - pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) + fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: std::error::Error + Send + Sync + 'static, @@ -1137,25 +854,43 @@ impl Backend { .detach(); } - /// Connect to a repository's announced relays, its NIP-34 `relays` tag. pub fn connect_repo_relays( &mut self, relays: Vec, filters: Vec, cx: &mut Context, ) { + if relays.is_empty() || filters.is_empty() { + return; + } + let client = self.client.clone(); cx.spawn(async move |_this, _cx| { - if let Err(e) = connect_repo_relays(&client, relays, filters).await { - log::warn!("repo relay fetch failed: {e}"); + let connected: Result<(), Error> = async { + for url in relays.iter() { + client.add_relay(url).and_connect().await?; + } + Ok(()) } + .await; + + if let Err(e) = connected { + log::warn!("repo relay fetch failed: {e}"); + return Ok::<(), Error>(()); + } + + for filter in filters.into_iter() { + if let Err(e) = client.sync(filter).with(relays.iter()).await { + log::warn!("repo relay negentropy sync failed: {e}"); + } + } + Ok::<(), Error>(()) }) .detach(); } - /// Sync filters through the SDK's NIP-65 gossip targeting. pub fn sync_auto(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -1187,49 +922,16 @@ impl Backend { .detach(); } - /// Sync several bootstrap filters in order, within a single task. pub fn sync_bootstraps(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); - let (tx, mut rx) = SyncProgress::channel(); - - self.sync_progress = Some((0, 0)); - cx.notify(); - - cx.spawn(async move |this, cx| { - let mut last_percent: u64 = 0; - - while rx.changed().await.is_ok() { - let progress = *rx.borrow_and_update(); - let percent = (progress.percentage() * 100.0) as u64; - - if progress.current > 0 && percent != last_percent { - last_percent = percent; - - let alive = this.update(cx, |this, cx| { - this.sync_progress = Some((progress.total, progress.current)); - cx.emit(BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }); - cx.notify(); - }); - - if alive.is_err() { - break; - } - } - } - - Ok::<(), anyhow::Error>(()) - }) - .detach(); let sync = cx.background_spawn(async move { let mut first_error = None; for filter in filters { - let opts = SyncOptions::default().progress(tx.clone()); - if let Err(error) = sync_bootstrap_only(&client, filter, opts).await { + if let Err(error) = + sync_bootstrap_only(&client, filter, SyncOptions::default()).await + { first_error.get_or_insert(error); } } @@ -1243,17 +945,13 @@ impl Backend { cx.spawn(async move |this, cx| { match sync.await { Ok(_) => { - this.update(cx, |this, cx| { - this.sync_progress = None; + this.update(cx, |_this, cx| { cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { - this.update(cx, |this, cx| { - this.sync_progress = None; - cx.emit(BackendEvent::error(e.to_string())) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } @@ -1262,20 +960,16 @@ impl Backend { .detach(); } - /// Publish a NIP-09 deletion for each of `events`, best-effort. - /// - /// Each target gets its own deletion event: a relay rejecting or - /// dropping one does not affect the others. + // Each target gets its own deletion event: a relay rejecting or dropping + // one does not affect the others. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { - let client = self.client.clone(); - let signer = self.signer.clone(); + let pusher = GraspPush::new(self.client.clone(), self.signer.clone()); for event in events.iter().cloned() { - let client = client.clone(); - let signer = signer.clone(); + let pusher = pusher.clone(); cx.spawn(async move |_this, _cx| { - if let Err(e) = retract_event(&client, &signer, &event).await { + if let Err(e) = pusher.retract_event(&event).await { log::warn!("failed to retract event {}: {e}", event.id); } }) @@ -1284,568 +978,152 @@ impl Backend { } } -/// A relay event the backend routes to a store group. -enum UpdateEvent { - Profile(PublicKey), - Repo(Update), -} - -/// Await the next relay event from the notification stream. -async fn next_update( - notifications: &mut (impl futures::Stream + Unpin), - seen: &mut HashSet, -) -> Option { - loop { - match notifications.next().await { - Some(ClientNotification::Message { message, .. }) => { - let RelayMessage::Event { event, .. } = *message else { - continue; - }; - - let update = match event.kind { - Kind::Metadata => UpdateEvent::Profile(event.pubkey), - kind if filters::is_repo_kind(kind) => { - UpdateEvent::Repo(Update::from_event(&event)) - } - _ => continue, - }; - - if seen.insert(event.id) { - return Some(update); - } - } - Some(_) => continue, - None => return None, - } +fn repository_announcement( + repo_id: &str, + name: &str, + description: &str, + owner: &str, + servers: &[RelayUrl], + euc: Option, +) -> GitRepositoryAnnouncement { + GitRepositoryAnnouncement { + id: repo_id.to_owned(), + name: Some(name.to_owned()), + description: (!description.is_empty()).then(|| description.to_owned()), + web: Vec::new(), + clone: servers + .iter() + .filter_map(|relay| grasp_clone_url(relay, owner, repo_id)) + .collect(), + relays: servers.to_vec(), + euc, + maintainers: Vec::new(), } } -/// Sign and send a single NIP-09 deletion request for `event`. -async fn retract_event( +// Announce the repository, then stage the state event and push: the state +// event is the push authorization, so it must be accepted before the push. +// Connect to all servers first — the nostr client queues events until each +// relay is connected. +#[allow(clippy::too_many_arguments)] +async fn announce_repository_and_push( + backend: &WeakEntity, client: &Client, signer: &UniversalSigner, - event: &Event, -) -> Result<(), Error> { - let builder = EventDeletionRequest::new() - .id(event.id) - .into_event_builder(); - - let deletion = builder.finalize_async(signer).await?; - client.send_event(&deletion).broadcast().await?; - - Ok(()) -} - -/// The event was accepted by at least one relay, or a descriptive error otherwise. -pub(crate) fn require_relay_accepted( - output: SendEventOutput, - event: Event, + announcement: GitRepositoryAnnouncement, + repo_id: &str, + owner: &str, + servers: &[RelayUrl], + refs: Vec<(String, String)>, + head: Option, + path: &Path, + cx: &mut AsyncApp, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static, ) -> Result { - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - bail!("event not accepted by any relay: {reasons}"); + for url in servers { + client.add_relay(url).and_connect().await.ok(); + } + + let event = { + let builder = announcement.into_event_builder(); + let event = builder.finalize_async(signer).await?; + let output = client.send_event(&event).broadcast().await?; + GraspPush::require_relay_accepted(output, event)? + }; + + // The state event is the push authorization. Stage it on each grasp + // server's relay, then push the git data. The push fails only when no + // server accepted it. The announcement is then retracted so the + // repository is not left announced without content. + let outcome = if refs.is_empty() { + PushOutcome::default() + } else { + let pusher = GraspPush::new(client.clone(), signer.clone()); + let repo_id = repo_id.to_owned(); + let owner = owner.to_owned(); + let servers = servers.to_vec(); + let head = head.clone(); + let path = path.to_path_buf(); + let executor = cx.background_executor().clone(); + cx.background_spawn(async move { + pusher + .push_staged_to_grasps( + &repo_id, + &refs, + head.as_deref(), + &path, + &owner, + &servers, + &executor, + push, + ) + .await + }) + .await + }; + + if outcome.accepted() == 0 { + // Retract the announcement so the repository is not left announced + // without content. + backend + .update(cx, |backend, cx| { + backend.retract_events(std::slice::from_ref(&event), cx); + }) + .ok(); + + return Err(anyhow!( + "The repository was announced, but the push to every grasp server failed: {}. \ + The announcement has been retracted", + outcome.failure_summary() + )); + } + + // Fan the state out to the relays once a git server holds the objects. + if let Some(state_event) = &outcome.state_event + && let Err(e) = client.send_event(state_event).broadcast().await + { + log::warn!("failed to broadcast repository state: {e}"); } Ok(event) } -/// Sign and broadcast `builder`, logging rather than surfacing failures. -/// -/// Used for best-effort identity bootstrap events, where a relay hiccup -/// should not block sign-up. -async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder: EventBuilder) { - let result: Result<(), Error> = async { - let event = builder.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)?; - Ok(()) - } - .await; - - if let Err(e) = result { - log::warn!("failed to publish identity bootstrap event: {e}"); - } +enum UpdateEvent { + Profile(PublicKey), + Repo(Update), } -async fn connect_repo_relays( - client: &Client, - relays: Vec, - filters: Vec, -) -> Result<(), Error> { - if relays.is_empty() || filters.is_empty() { - return Ok(()); - } +impl UpdateEvent { + async fn next( + notifications: &mut (impl futures::Stream + Unpin), + seen: &mut HashSet, + ) -> Option { + loop { + match notifications.next().await { + Some(ClientNotification::Message { message, .. }) => { + let RelayMessage::Event { event, .. } = *message else { + continue; + }; - for url in relays.iter() { - client.add_relay(url).and_connect().await?; - } + let update = match event.kind { + Kind::Metadata => UpdateEvent::Profile(event.pubkey), + kind if filters::is_repo_kind(kind) => { + UpdateEvent::Repo(Update::from_event(&event)) + } + _ => continue, + }; - for filter in filters.into_iter() { - if let Err(e) = client.sync(filter).with(relays.iter()).await { - log::warn!("repo relay negentropy sync failed: {e}"); - } - } - - Ok(()) -} - -/// Add and connect the startup relays. -async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { - for url in BOOTSTRAP_RELAYS { - client.add_relay(url).and_connect().await?; - } - - for url in INDEXER_RELAYS { - client - .add_relay(url) - .capabilities(RelayCapabilities::DISCOVERY) - .and_connect() - .await?; - } - - Ok(()) -} - -pub(crate) async fn subscribe_bootstrap_only( - client: &Client, - filters: Vec, -) -> Result<(), Error> { - ensure_bootstrap_relays(client).await?; - - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(10))); - - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .iter() - .map(|relay| (*relay, filters.clone())) - .collect(); - - client.subscribe(target).close_on(opts).await?; - - Ok(()) -} - -pub(crate) async fn sync_bootstrap_only( - client: &Client, - filter: Filter, - opts: SyncOptions, -) -> Result { - ensure_bootstrap_relays(client).await?; - - let output = client - .sync(filter) - .with(BOOTSTRAP_RELAYS) - .opts(opts) - .await?; - - Ok(output.value) -} - -/// Embed a NIP-46 session key into a bunker URI as `?master=`. -fn with_master_key(uri: &str, keys: &Keys) -> String { - let separator = if uri.contains('?') { '&' } else { '?' }; - let nsec = keys.secret_key().to_bech32().expect("infallible"); - format!("{uri}{separator}master={nsec}") -} - -/// Base URL of a grasp server, `https://`. -/// -/// `ws://` grasp servers use `http://`, like ngit. -pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { - // `domain()` drops the port. - let parsed = Url::parse(relay.as_str()).ok()?; - let host = parsed.host_str()?; - let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); - // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP. - let scheme = if relay.scheme().is_secure() { - "https" - } else { - "http" - }; - Some(format!("{scheme}://{host}{port}")) -} - -fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { - let base = grasp_base_url(relay)?; - Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() -} - -/// GRASP-06 contributor namespace URL of a pull request tip. -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 come first. -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. -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() -} - -/// Grasp servers of the newest kind-10317 grasp list among `events`. -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() -} - -pub 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)) -} - -const GRASP_PUSH_ATTEMPTS: usize = 3; - -/// Pause before re-staging a state event after a transient denial. -const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); - -#[derive(Debug, Clone)] -pub struct GraspServerResult { - pub relay: RelayUrl, - /// `None` when the server accepted the data, the reason otherwise. - pub reason: Option, -} - -impl GraspServerResult { - fn ok(relay: RelayUrl) -> Self { - Self { - relay, - reason: None, - } - } - - fn failed(relay: RelayUrl, reason: impl Into) -> Self { - Self { - relay, - reason: Some(reason.into()), - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct PushOutcome { - /// Per-server results, in the order the servers were listed. - pub servers: Vec, - /// The newest state event a grasp relay accepted for this push, if any. - /// - /// Broadcast to the other relays once a git server holds the data. - pub state_event: Option, -} - -impl PushOutcome { - pub fn accepted(&self) -> usize { - self.servers - .iter() - .filter(|server| server.reason.is_none()) - .count() - } - - fn failing(&self) -> impl Iterator { - self.servers.iter().filter(|server| server.reason.is_some()) - } - - pub fn failure_summary(&self) -> String { - self.failing() - .map(|server| { - let reason = - flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); - format!("{}: {reason}", server.relay) - }) - .collect::>() - .join("; ") - } - - /// A warning for a push only some grasp servers accepted. - /// - /// `None` when every server accepted the push or nothing was pushed. - pub fn partial_warning(&self) -> Option { - let accepted = self.accepted(); - if self.servers.is_empty() || accepted == self.servers.len() { - return None; - } - Some(format!( - "Pushed to {accepted} of {} grasp servers: {}. Republish to sync.", - self.servers.len(), - self.failure_summary() - )) - } -} - -fn flatten_whitespace(text: &str) -> String { - const MAX_CHARS: usize = 200; - let flat: String = text.split_whitespace().collect::>().join(" "); - if flat.chars().count() <= MAX_CHARS { - flat - } else { - let mut clipped: String = flat.chars().take(MAX_CHARS).collect(); - clipped.push('…'); - clipped - } -} - -/// Reasons a push attempt should be retried with a freshly staged state -/// event and a fresh git advertisement. -/// -/// Two families are retried: -/// -/// - **Purgatory denials**: the grasp server sends these when the state -/// event for the push has not reached its purgatory yet. Re-staging a -/// fresh event resolves them. -/// - **Stale advertisement races**: `git receive-pack` compares each ref -/// update against the value it advertised when the push started. The grasp -/// server's own background sync can move a ref in between - typically by -/// aligning the repository to a parked state event once the objects of an -/// earlier attempt land - so the compare-and-swap fails with `cannot lock -/// ref` / `incorrect old value provided`. A retry against the fresh -/// advertisement converges, and when the race is lost the pushed data is -/// usually already on the server (see `is_stale_advertisement_race` and -/// the convergence probe in `push_staged_to_grasps`). -/// -/// Other rejections are not retried. -fn is_transient_grasp_denial(stderr: &str) -> bool { - let error = stderr.to_lowercase(); - [ - "no state events in purgatory", - "no matching state event", - "doesn't match push", - "none from authorized publishers", - "no repository announcement found", - "cannot lock ref", - "incorrect old value provided", - ] - .iter() - .any(|marker| error.contains(marker)) -} - -/// A push rejected because `git receive-pack`'s compare-and-swap lost to the -/// grasp server's own background ref alignment: the ref moved between this -/// push's advertisement and its ref transaction (`cannot lock ref ... is at -/// ... but expected ...` / `incorrect old value provided`). The pushed data -/// is usually already on the server by then. -fn is_stale_advertisement_race(stderr: &str) -> bool { - let error = stderr.to_lowercase(); - error.contains("cannot lock ref") || error.contains("incorrect old value provided") -} - -/// Keep `event` as the push's fan-out state event when it is newer than the -/// current one. All staged events carry the same refs; the newest timestamp -/// wins on the relays. -fn keep_newest(state_event: &mut Option, event: Event) { - if state_event - .as_ref() - .is_none_or(|current| event.created_at > current.created_at) - { - *state_event = Some(event); - } -} - -/// Sign a fresh kind `30618` state event for the push. -/// -/// `last_created_at` is the timestamp of the previous event signed for this push. -/// Retries within the same second get the next second: a grasp relay -/// treats a same-id resend as a duplicate and does not re-run its ingest, -/// so an identical resend cannot re-park a state event lost from its purgatory. -async fn sign_state_event( - signer: &UniversalSigner, - repo_id: &str, - refs: &[(String, String)], - head: Option<&str>, - last_created_at: u64, -) -> Result<(Event, u64), String> { - let now = Timestamp::now().as_secs(); - let created_at = if now > last_created_at { - now - } else { - last_created_at + 1 - }; - - let event = build_state(repo_id, refs, head) - .custom_created_at(Timestamp::from_secs(created_at)) - .finalize_async(signer) - .await - .map_err(|e| format!("could not sign the state event: {e}"))?; - - Ok((event, created_at)) -} - -/// Ensure the relay is known and connected, then publish `event` to it. -/// -/// `Ok` only when the relay confirmed the event. -/// On a grasp relay the accept parks the event in purgatory, -/// which authorizes the paired git push. -async fn stage_event_on_relay( - client: &Client, - relay: &RelayUrl, - event: &Event, -) -> Result<(), String> { - client - .add_relay(relay) - .and_connect() - .await - .map_err(|e| format!("could not add relay {relay}: {e}"))?; - - let output = client - .send_event(event) - .to([relay.clone()]) - .await - .map_err(|e| format!("could not send the state event to {relay}: {e}"))?; - - if output.success.contains_key(relay) { - Ok(()) - } else { - let reason = output - .failed - .get(relay) - .cloned() - .unwrap_or_else(|| "relay did not confirm the event".to_owned()); - Err(reason) - } -} - -#[allow(clippy::too_many_arguments)] -async fn push_staged_to_grasps( - client: &Client, - signer: &UniversalSigner, - repo_id: &str, - refs: &[(String, String)], - head: Option<&str>, - path: &Path, - owner: &str, - servers: &[RelayUrl], - executor: &BackgroundExecutor, - push: fn(&Path, &str, &str, &str) -> Result<(), Error>, -) -> PushOutcome { - let mut outcome = PushOutcome::default(); - - for relay in servers { - let Some(base) = grasp_base_url(relay) else { - outcome - .servers - .push(GraspServerResult::failed(relay.clone(), "no domain")); - continue; - }; - let git_url = format!("{base}/{owner}/{repo_id}.git"); - - let mut reason = None; - let mut last_created_at = 0; - // The last state event staged on this server, for the convergence - // probe below when every push attempt lost the stale-ref race. - let mut staged_event = None; - - 'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS { - if attempt > 1 { - // Give the server's ingest a moment before re-staging. - executor.timer(GRASP_RETRY_DELAY).await; - } - - let (event, created_at) = - match sign_state_event(signer, repo_id, refs, head, last_created_at).await { - Ok(signed) => signed, - Err(e) => { - reason = Some(e); - break 'server; + if seen.insert(event.id) { + return Some(update); } - }; - - last_created_at = created_at; - - // Stage the state event on this server's own relay. - // A failed stage means the grasp never parked the state, - // so the git push would be denied anyway: skip it (the eligibility gate). - if let Err(e) = stage_event_on_relay(client, relay, &event).await { - // One retry absorbs a relay connect blip, on the first - // attempt only. - if attempt == 1 && stage_event_on_relay(client, relay, &event).await.is_ok() { - // staged on the retry - } else { - reason = Some(e); - break 'server; } + Some(_) => continue, + None => return None, } - staged_event = Some(event.clone()); - - match push(path, &base, owner, repo_id) { - Ok(()) => { - keep_newest(&mut outcome.state_event, event); - break 'server; - } - Err(e) => { - let text = e.to_string(); - if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) { - reason = Some(text); - continue 'server; - } - reason = Some(text); - break 'server; - } - } - } - - // The grasp's own background sync aligns refs to staged state - // events as soon as the objects land, which can beat every push - // attempt's compare-and-swap (`cannot lock ref ... but expected`). - // When the last denial was that race the sync has usually finished - // by now: verify the advertised refs and accept the server when the - // pushed data is already there. - if let Some(last_reason) = &reason - && is_stale_advertisement_race(last_reason) - && signed_git::remote_has_refs(path, &git_url, refs).unwrap_or(false) - { - if let Some(event) = staged_event { - keep_newest(&mut outcome.state_event, event); - } - reason = None; - } - - match reason { - Some(reason) => { - log::warn!("grasp push failed: {relay}: {reason}"); - outcome - .servers - .push(GraspServerResult::failed(relay.clone(), reason)); - } - None => outcome.servers.push(GraspServerResult::ok(relay.clone())), } } - - outcome } -/// Split a stored bunker credential into the plain URI and the session key. fn extract_master_key(credential: &str) -> (&str, Keys) { match credential.split_once("master=") { Some((base, nsec)) => { @@ -1857,164 +1135,3 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { None => (credential, Keys::generate()), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn grasp_base_url_maps_schemes_like_ngit() { - let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); - assert_eq!( - grasp_base_url(&wss).as_deref(), - Some("https://relay.ngit.dev") - ); - - let ws = RelayUrl::parse("ws://localhost:8080").expect("url"); - assert_eq!( - grasp_base_url(&ws).as_deref(), - Some("http://localhost:8080") - ); - } - - #[test] - fn grasp_clone_url_matches_ngit_format() { - let relay = RelayUrl::parse("wss://gitnostr.com").expect("url"); - let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url"); - assert_eq!( - url.to_string(), - "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", - ] - ); - } - - #[test] - fn transient_grasp_denials_are_classified() { - // The exact server rejection that started this work: the state event - // had not reached the grasp's purgatory before the git push. - let reported = "remote: ERR authorisation failed: No state events in purgatory\n\ - fatal: the remote end hung up unexpectedly\n\ - error: failed to push some refs to 'https://relay.ngit.dev/...git'"; - assert!(is_transient_grasp_denial(reported)); - - // The other purgatory states a fresh event resolves. - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: No matching state event found in purgatory" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: 1 state event in purgatory from authorized \ - publisher but doesn't match push" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: 2 state events in purgatory but none from \ - authorized publishers" - )); - assert!(is_transient_grasp_denial( - "remote: ERR authorisation failed: No repository announcement found" - )); - - // Rejections a fresh state event cannot fix are not retried. - assert!(!is_transient_grasp_denial( - "remote: ERR authorisation failed: not a maintainer of this repository" - )); - assert!(!is_transient_grasp_denial( - "fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \ - error: 403" - )); - assert!(!is_transient_grasp_denial( - "fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host" - )); - } - - #[test] - fn stale_ref_races_are_retried() { - // The grasp's background sync aligned the ref to a parked state event - // between this push's advertisement and its ref transaction. The ref - // is usually already where the push wants it, so a retry converges. - let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \ - cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \ - bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\ - To https://relay.ngit.dev/npub1owner/signed-test.git\n\ - ! [remote rejected] main -> main (incorrect old value provided)\n\ - error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'"; - assert!(is_transient_grasp_denial(reported)); - assert!(is_stale_advertisement_race(reported)); - - // Markers match independently of the surrounding git output. - assert!(is_stale_advertisement_race( - "cannot lock ref 'refs/heads/main'" - )); - assert!(is_stale_advertisement_race( - "! [remote rejected] main -> main (incorrect old value provided)" - )); - - // A purgatory denial is not a stale-advertisement race. - assert!(!is_stale_advertisement_race("No state events in purgatory")); - - // A real divergence is a different error and stays permanent. - assert!(!is_transient_grasp_denial( - " ! [rejected] main -> main (non-fast-forward)" - )); - } - - #[test] - fn push_outcome_reports_partial_failures() { - let outcome = PushOutcome { - servers: vec![ - GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")), - GraspServerResult::failed( - RelayUrl::parse("wss://relay.ngit.dev").expect("url"), - "remote: ERR authorisation failed: No state events in purgatory\nfatal: ...", - ), - ], - state_event: None, - }; - - assert_eq!(outcome.accepted(), 1); - assert_eq!( - outcome.failure_summary(), - "wss://relay.ngit.dev: remote: ERR authorisation failed: No state events in \ - purgatory fatal: ..." - ); - let warning = outcome.partial_warning().expect("partial push warning"); - assert!(warning.starts_with("Pushed to 1 of 2 grasp servers")); - assert!(warning.contains("Republish to sync")); - // The multi-line server reason is a single display line. - assert_eq!(warning.lines().count(), 1); - } -} diff --git a/crates/signed_state/src/bootstrap.rs b/crates/signed_state/src/bootstrap.rs new file mode 100644 index 0000000..eb6c360 --- /dev/null +++ b/crates/signed_state/src/bootstrap.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Error; +use nostr_connect::prelude::*; +use nostr_sdk::client::SyncSummary; +use nostr_sdk::prelude::*; +use signed_core::Filters; + +pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"]; +pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; + +async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> { + for url in BOOTSTRAP_RELAYS { + client.add_relay(url).and_connect().await?; + } + + for url in INDEXER_RELAYS { + client + .add_relay(url) + .capabilities(RelayCapabilities::DISCOVERY) + .and_connect() + .await?; + } + + Ok(()) +} + +pub(crate) async fn subscribe_bootstrap_only( + client: &Client, + filters: Vec, +) -> Result<(), Error> { + ensure_bootstrap_relays(client).await?; + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(10))); + + let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS + .iter() + .map(|relay| (*relay, filters.clone())) + .collect(); + + client.subscribe(target).close_on(opts).await?; + + Ok(()) +} + +pub(crate) async fn sync_bootstrap_only( + client: &Client, + filter: Filter, + opts: SyncOptions, +) -> Result { + ensure_bootstrap_relays(client).await?; + + let output = client + .sync(filter) + .with(BOOTSTRAP_RELAYS) + .opts(opts) + .await?; + + Ok(output.value) +} + +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() +} + +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() +} + +pub 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)) +} diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 7822c01..1d2b827 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -7,20 +7,19 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr::prelude::*; use settings::{CheckoutRecord, SettingsStore}; use signed_core::{Announcement, RepoAddr}; +use signed_git::Repo; +use utils::same_repo_url; use crate::backend::{Backend, BackendEvent}; -use crate::git_store::repo_mirror_root; +use crate::git_store::Mirrors; use crate::local_repos::LocalReposStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::RepoListStore; const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); -/// How often the statuses are recomputed against the local refs. const LOCAL_POLL: Duration = Duration::from_secs(2); -/// How often a full pass refreshes the remotes while any repository panel is open. const STATUS_POLL: Duration = Duration::from_secs(15); -/// Remote refresh interval for the `ready to push` badges of the user's own repositories. const PUSH_POLL: Duration = Duration::from_secs(60); const MAX_STATUS_CHECKOUTS: usize = 8; @@ -29,63 +28,37 @@ struct GlobalCheckoutsStore(Entity); impl Global for GlobalCheckoutsStore {} -/// One associated local checkout of a repository. -/// -/// Carries the git facts needed to suggest a pull request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckoutStatus { pub path: PathBuf, - /// The branch checked out. A detached checkout is idle and yields no status. + // A detached checkout is idle and yields no status. pub branch: String, - /// Commit the branch points at, for tip-based PR dedupe. + // For tip-based PR dedupe. pub head: String, - /// What the branch is compared against. - /// - /// It is `refs/remotes/origin/`, else `origin/HEAD` for new branches. + // `refs/remotes/origin/`, else `origin/HEAD` for new branches. pub base: String, - /// Commits in `base..branch`. - /// - /// Zero-ahead checkouts are dropped, so this is always above zero. + // Zero-ahead checkouts are dropped, so always above zero. 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. by_repo: HashMap>, - /// Ready-to-contribute statuses of the requested repositories. - /// - /// Those are the repository detail panels currently open. statuses: HashMap>, - /// Repositories whose statuses are recomputed on every input change. - /// - /// Those are the repository detail panels currently open. status_requested: HashSet, - /// Repositories whose `ready to push` statuses are recomputed on the same cycle. - /// - /// The sidebar rows of the user's own repositories and their detail panels. push_requested: HashSet, - /// The ready-to-push statuses of the requested own repositories. push_statuses: HashMap>, - /// Last announced head branch per requested repository. - /// - /// A recompute defaults the base the same way. requested_head: HashMap>, refresh: RefreshGate, - /// True while the timer between a scheduled refresh and its run is pending. debounce_pending: bool, local_pending: bool, - /// When the last full pass (with a remote refresh) completed. - /// - /// The local pass runs a full pass again once this is older than the - /// reconciliation cadence, so remote moves still land. + // The local pass runs a full pass again once this is older than the + // reconciliation cadence, so remote moves still land. last_full_sync: Option, _subscriptions: Vec, } @@ -187,17 +160,10 @@ impl CheckoutsStore { }); } - /// 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 stay current. - /// Called while the repository's detail panel is open. - /// - /// `announced_head` is the announced HEAD branch, used to default the base. pub fn request_statuses( &mut self, addr: &RepoAddr, @@ -211,26 +177,18 @@ impl CheckoutsStore { self.refresh(cx); } - /// The ready-to-contribute statuses of `addr`. - /// - /// Empty while none are known or nothing is ahead. pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec { self.statuses.get(addr).cloned().unwrap_or_default() } - /// Ask for the `ready to push` statuses of `addr` to stay current. pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context) { self.push_requested.insert(addr.clone()); self.refresh(cx); } - /// The checkout at `path` was just pushed to the remote. - /// - /// Its ready-to-push status is obsolete. Drop it from the cached statuses - /// and notify observers right away, so the sidebar badge and the push - /// banner update immediately instead of waiting for the next background - /// pass, which re-scans and re-fetches the remote. The debounced refresh - /// reconciles the remaining checkouts of the repository afterwards. + // Drop the stale ready-to-push status and notify observers right away, so + // the sidebar badge updates immediately instead of waiting for the next + // background pass. The debounced refresh reconciles the remaining checkouts. pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context) { let mut removed = false; @@ -253,9 +211,6 @@ impl CheckoutsStore { self.request_push_statuses(addr, cx); } - /// The ready-to-push statuses of `addr`. - /// - /// Empty while none are known or nothing is unpushed. pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec { self.push_statuses.get(addr).cloned().unwrap_or_default() } @@ -267,7 +222,6 @@ impl CheckoutsStore { .unwrap_or(0) } - /// Re-resolve the associations and the requested statuses. pub fn refresh(&mut self, cx: &mut Context) { if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule { return; @@ -282,7 +236,6 @@ impl CheckoutsStore { .detach(); } - /// One full resolve and apply cycle, the debounced entry point. fn run_refresh(&mut self, cx: &mut Context) { self.debounce_pending = false; self.refresh.begin(); @@ -306,7 +259,7 @@ impl CheckoutsStore { let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); let scanned = LocalReposStore::global(cx).read(cx).repos.clone(); - let cache_root = repo_mirror_root().canonicalize().ok(); + let cache_root = Mirrors::root().canonicalize().ok(); let requested: Vec<(RepoAddr, Option)> = self .status_requested @@ -323,35 +276,38 @@ impl CheckoutsStore { let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty(); let work = cx.background_spawn(async move { - // Read the git facts of every scanned repository off the main thread. - // - // The facts are the origin URL and the root commit, both CLI reads. let mut facts: Vec<(PathBuf, Option, Option)> = Vec::new(); for scanned in scanned.iter() { let path = &scanned.path; - // The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts. + // The browser's mirror clones 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(); + let origin = Repo::open(path) + .and_then(|repo| repo.origin_url()) + .ok() + .flatten(); + let root = Repo::open(path) + .and_then(|repo| repo.root_commit()) + .ok() + .flatten(); facts.push((path.clone(), origin, root)); } - let associations = resolve_associations(&remembered, &facts, announcements.iter()); + let associations = + CheckoutsStore::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 (statuses, push_statuses) = - compute_statuses(&associations, &requested, &push_requested, true); + CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, true); Ok::<_, Error>((associations, statuses, push_statuses)) }); @@ -360,7 +316,6 @@ impl CheckoutsStore { let (associations, statuses, push_statuses) = match work.await { Ok(results) => results, Err(_) => { - // Git reads are best-effort, keep the last results. return this.update(cx, |this, cx| { this.refresh.abort(); if poll { @@ -379,8 +334,7 @@ impl CheckoutsStore { this.statuses = statuses; this.push_statuses = push_statuses; - // Notify only when something actually changed, so observers - // skip the no-op heartbeats. + // Notify only when something actually changed. if associations_changed || statuses_changed || push_statuses_changed { cx.notify(); } @@ -393,9 +347,6 @@ impl CheckoutsStore { this.update(cx, |this, cx| this.refresh(cx))?; } - // Restart the fast local pass so the freshly resolved - // associations drive it. The pass itself decides when the next - // full pass runs. this.update(cx, |this, cx| { if poll { this.schedule_local_pass(cx); @@ -407,7 +358,6 @@ impl CheckoutsStore { .detach(); } - /// Schedule the fast local status pass, unless one is already pending. fn schedule_local_pass(&mut self, cx: &mut Context) { if self.local_pending { return; @@ -424,20 +374,17 @@ impl CheckoutsStore { .detach(); } - /// The fast local status pass. fn local_tick(&mut self, cx: &mut Context) { - // Nothing watched: the chain idles out until a new request restarts it. + // Nothing watched: the pass idles until a new request restarts it. if self.status_requested.is_empty() && self.push_requested.is_empty() { return; } - // A full pass or a fresh request covers this tick, skip it. if self.refresh.running() || self.debounce_pending { self.schedule_local_pass(cx); return; } - // Open panels get the faster remote cadence. let cadence = if self.status_requested.is_empty() { PUSH_POLL } else { @@ -458,7 +405,6 @@ impl CheckoutsStore { self.schedule_local_pass(cx); } - /// Recompute the requested statuses against the tracking refs only. fn run_local_statuses(&mut self, cx: &mut Context) { let associations = self.by_repo.clone(); @@ -477,19 +423,18 @@ impl CheckoutsStore { let work = cx.background_spawn(async move { let (statuses, push_statuses) = - compute_statuses(&associations, &requested, &push_requested, false); + CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, false); Ok::<_, Error>((statuses, push_statuses)) }); let task: gpui::Task> = cx.spawn(async move |this, cx| { let Ok((statuses, push_statuses)) = work.await else { - // Git reads are best-effort, keep the last results. return Ok(()); }; this.update(cx, |this, cx| { - // A full pass or a fresh request will apply fresher data - // (the tracking refs move only when a full pass fetches). + // The tracking refs move only when a full pass fetches; a full + // pass or a fresh request will apply fresher data. if this.refresh.running() || this.debounce_pending { return; } @@ -511,217 +456,201 @@ impl CheckoutsStore { } } -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)) -} +impl CheckoutsStore { + 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(); -fn same_repo_url(a: &str, b: &str) -> bool { - match (url_identity(a), url_identity(b)) { - (Some(a), Some(b)) => a == b, - _ => a == b, - } -} - -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()); + 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())) - }); + 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)); + 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()); + if url_match || euc_match { + let paths = out.entry(announcement.addr()).or_default(); + if !paths.contains(path) { + paths.push(path.clone()); + } } } } + + out } - out -} + fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { + let repo = Repo::try_open(path)?; + let branches = repo.branches().ok()?; -fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { - let branches = signed_git::worktree_branches(path).ok()?; + if branches.is_empty() || repo.is_dirty() { + return None; + } - if branches.is_empty() || signed_git::worktree_dirty(path) { - return None; + let branch = repo.current_branch()?; + let head = repo.head()?; + 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 = repo.commits_ahead(&base, &branch); + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) } - let branch = signed_git::worktree_current_branch(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())?; + // Never fetches the checked-out refs; reads the tracking refs as-is. + fn checkout_push_status(path: &Path, fetch: bool) -> Option { + let repo = Repo::try_open(path)?; + if repo.is_dirty() { + return None; + } - if base == branch { - return None; - } + let branch = repo.current_branch()?; + let head = repo.head()?; + let origin = repo.origin_url().ok().flatten()?; - let ahead = signed_git::worktree_commits_ahead(path, &base, &branch); - (ahead > 0).then_some(CheckoutStatus { - path: path.to_path_buf(), - branch, - head, - base, - ahead, - }) -} + if fetch { + repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") + .ok(); + } -/// The `ready to push` status of one checkout of the user's own repository. -fn checkout_push_status(path: &Path, fetch: bool) -> Option { - if signed_git::worktree_dirty(path) { - return None; - } + let remote = format!("refs/remotes/origin/{branch}"); - let branch = signed_git::worktree_current_branch(path)?; - let head = signed_git::head_commit_id(path).ok().flatten()?; - let origin = signed_git::origin_url(path).ok().flatten()?; - - if fetch { - signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); - } - - let remote = format!("refs/remotes/origin/{branch}"); - - // A branch never fetched or pushed yet compares against the remote HEAD. - // The remote HEAD is the fork point in practice. - let base = if signed_git::worktree_ref_exists(path, &remote) { - remote - } else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") { - "refs/remotes/origin/HEAD".to_owned() - } else { - return None; - }; - - let ahead = signed_git::worktree_commits_ahead(path, &base, &branch); - - (ahead > 0).then_some(CheckoutStatus { - path: path.to_path_buf(), - branch, - head, - base, - ahead, - }) -} - -/// Compute the requested statuses against the checkout paths of `associations`. -fn compute_statuses( - associations: &HashMap>, - requested: &[(RepoAddr, Option)], - push_requested: &[RepoAddr], - fetch: bool, -) -> ( - HashMap>, - HashMap>, -) { - let mut statuses: HashMap> = HashMap::new(); - for (addr, announced_head) in requested { - let Some(paths) = associations.get(addr) else { - continue; + // A branch never fetched or pushed yet compares against the remote + // HEAD, the fork point in practice. + let base = if repo.ref_exists(&remote) { + remote + } else if repo.ref_exists("refs/remotes/origin/HEAD") { + "refs/remotes/origin/HEAD".to_owned() + } else { + return None; }; - let list: Vec = paths - .iter() - .take(MAX_STATUS_CHECKOUTS) - .filter_map(|path| checkout_status(path, announced_head.as_deref())) - .collect(); + let ahead = repo.commits_ahead(&base, &branch); - if !list.is_empty() { - statuses.insert(addr.clone(), list); + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) + } + + fn compute_statuses( + associations: &HashMap>, + requested: &[(RepoAddr, Option)], + push_requested: &[RepoAddr], + fetch: bool, + ) -> ( + HashMap>, + HashMap>, + ) { + 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| Self::checkout_status(path, announced_head.as_deref())) + .collect(); + + if !list.is_empty() { + statuses.insert(addr.clone(), list); + } } - } - let mut push_statuses: HashMap> = HashMap::new(); - for addr in push_requested { - let Some(paths) = associations.get(addr) else { - continue; - }; + let mut push_statuses: HashMap> = HashMap::new(); + for addr in push_requested { + let Some(paths) = associations.get(addr) else { + continue; + }; - let list: Vec = paths - .iter() - .take(MAX_STATUS_CHECKOUTS) - .filter_map(|path| checkout_push_status(path, fetch)) - .collect(); + let list: Vec = paths + .iter() + .take(MAX_STATUS_CHECKOUTS) + .filter_map(|path| Self::checkout_push_status(path, fetch)) + .collect(); - if !list.is_empty() { - push_statuses.insert(addr.clone(), list); + if !list.is_empty() { + push_statuses.insert(addr.clone(), list); + } } + + (statuses, push_statuses) } - (statuses, push_statuses) -} + 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; + } -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 } - - 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 std::process::Command; - 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" @@ -730,7 +659,6 @@ mod tests { "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" @@ -739,113 +667,15 @@ mod tests { "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. - /// Takes `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_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 the path 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 _initial = Repo::init(&path, "My Repo", "").expect("init"); let run = |args: &[&str]| { let status = Command::new("git") .current_dir(&path) @@ -864,35 +694,28 @@ mod tests { 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"); + let status = CheckoutsStore::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()); + assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none()); run(&["checkout", "--", "."]); - // Even on main, nothing to propose. run(&["checkout", "main"]); - assert_eq!(checkout_status(&path, Some("main")), None); + assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None); } #[test] fn checkout_push_status_counts_unpushed_commits_only() { - // The `grasp remote` is a plain repository the checkout clones from. - // Its origin URL is a local path, so the whole cycle runs offline. - // Git refuses pushes to a checked-out branch by default. - // Act like a grasp server and allow them. let dir = tempfile::tempdir().expect("tempdir"); let remote = dir.path().join("remote"); - signed_git::init_repository(&remote, "My Repo", "").expect("init"); + Repo::init(&remote, "My Repo", "").expect("init"); let config = Command::new("git") .args(["config", "receive.denyCurrentBranch", "ignore"]) .current_dir(&remote) @@ -927,29 +750,23 @@ mod tests { }; // A fresh clone has nothing to push. - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); - // One local commit, ready to push, counted against the remote. std::fs::write(checkout.join("work.txt"), "x\n").expect("write"); run(&["add", "-A"]); run(&["commit", "-m", "local work"]); - let status = checkout_push_status(&checkout, true).expect("status"); + let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status"); assert_eq!(status.branch, "main"); assert_eq!(status.base, "refs/remotes/origin/main"); assert_eq!(status.ahead, 1); assert_eq!(status.head.len(), 40); - // The local-only pass reads the tracking refs, no fetch needed: - // a commit lands locally long before the remote is reconciled. - let local = checkout_push_status(&checkout, false).expect("local status"); + let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status"); assert_eq!(local.ahead, 1); - // After the push the same commit is on the remote, idle again. run(&["push", "origin", "main"]); - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); - // A commit made by someone else on the remote must not count as local work. - // It is behind, not ahead. let remote_run = |args: &[&str]| { let status = Command::new("git") .current_dir(&remote) @@ -965,6 +782,6 @@ mod tests { std::fs::write(remote.join("other.txt"), "y\n").expect("write"); remote_run(&["add", "-A"]); remote_run(&["commit", "-m", "remote work"]); - assert_eq!(checkout_push_status(&checkout, true), None); + assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None); } } diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 00cc6da..8048b90 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -2,40 +2,40 @@ use std::path::PathBuf; use std::sync::OnceLock; use anyhow::Result; -use gix::Repository; +use nostr::prelude::Url; use signed_core::RepoAddr; -use signed_git::GitCache; +use signed_git::{GitCache, Repo}; static GIT_CACHE: OnceLock = OnceLock::new(); -fn git_cache() -> &'static GitCache { - GIT_CACHE - .get() - .expect("git cache is initialized by signed_state::init") -} +pub struct Mirrors; -/// The root directory of the repository mirrors. -pub(crate) fn repo_mirror_root() -> PathBuf { - git_cache().root().to_path_buf() -} +impl Mirrors { + pub fn install(root: impl Into) { + if GIT_CACHE.set(GitCache::new(root.into())).is_err() { + log::warn!("git cache root is already set, keeping the first one"); + } + } -/// The on-disk path of the mirror of `addr`. -pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf { - git_cache().repo_path(addr) -} + fn cache() -> &'static GitCache { + GIT_CACHE + .get() + .expect("git cache is initialized by signed_state::init") + } -/// Open the mirror of `addr`, if it has been cloned. -pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { - git_cache().open(addr) -} + pub(crate) fn root() -> PathBuf { + Self::cache().root().to_path_buf() + } -/// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror>(addr: &RepoAddr, clone_urls: &[U]) -> Result { - git_cache().ensure_clone(addr, clone_urls) -} + pub fn path(addr: &RepoAddr) -> PathBuf { + Self::cache().repo_path(addr) + } -pub(crate) fn set_git_cache(root: impl Into) { - if GIT_CACHE.set(GitCache::new(root.into())).is_err() { - log::warn!("git cache root is already set, keeping the first one"); + pub fn open(addr: &RepoAddr) -> Result> { + Self::cache().open(addr) + } + + pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result { + Self::cache().ensure_clone(addr, clone_urls) } } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index aed4baa..69bab27 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -3,11 +3,10 @@ use std::collections::{HashMap, HashSet}; use anyhow::Error; use gpui::{AppContext, Context, Task}; use nostr_sdk::prelude::*; -use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; +use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox}; use crate::backend::Backend; -/// The user's persisted inbox read state. #[derive(Default)] pub struct Inbox { state: InboxReadState, @@ -15,7 +14,6 @@ pub struct Inbox { } impl Inbox { - /// The current read/archive cutoffs. pub fn state(&self) -> &InboxReadState { &self.state } @@ -24,41 +22,6 @@ impl Inbox { self.loaded } - pub fn mark_read( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_read(event); - } - self.state.advance_read(all, me, Timestamp::now()); - self.persist(cx); - cx.notify(); - } - - /// Archived events are always read too. - pub fn mark_archived( - &mut self, - group: &[Event], - all: &[Event], - me: PublicKey, - cx: &mut Context, - ) { - for event in group { - self.state.mark_archived(event); - self.state.mark_read(event); - } - - let now = Timestamp::now(); - self.state.advance_archived(all, me, now); - self.state.advance_read(all, me, now); - self.persist(cx); - cx.notify(); - } - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { self.state.mark_all_read(all, me, Timestamp::now()); self.persist(cx); @@ -71,7 +34,7 @@ impl Inbox { cx.notify(); let backend = Backend::global(cx); - let work = cx.background_spawn(async move { load_state(&client, me).await }); + let work = cx.background_spawn(async move { Self::load_state(&client, me).await }); cx.spawn(async move |this, cx| { let loaded = work.await; @@ -102,7 +65,6 @@ impl Inbox { cx.notify(); } - /// Sign the state with a random key and store it locally. fn persist(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let (me, client) = { @@ -117,7 +79,7 @@ impl Inbox { let state = self.state.clone(); let task: Task> = cx.background_spawn(async move { - if let Err(error) = save_state(&client, me, &state).await { + if let Err(error) = Self::save_state(&client, me, &state).await { log::warn!("failed to save inbox state: {error}"); } Ok(()) @@ -125,135 +87,144 @@ impl Inbox { task.detach(); } -} -pub async fn query_inbox( - client: &Client, - me: PublicKey, - state: &InboxReadState, -) -> Result<(Vec, usize), Error> { - let deletion_events = client.database().query(filters::deletions()).await?; - let deletions = Deletions::from_events(deletion_events); + pub async fn query( + client: &Client, + me: PublicKey, + state: &InboxReadState, + ) -> Result<(Vec, usize), Error> { + let deletion_events = client.database().query(Filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); - let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; + let (notification_events, mut by_id) = + Self::fetch_notifications(client, me, &deletions).await?; - let mut activity = Vec::new(); - for event in client - .database() - .query(filters::authored_activity(me)) - .await? - { - if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { - continue; - } - by_id.entry(event.id).or_insert_with(|| event.clone()); - activity.push(event); - } - - let items = inbox::group(notification_events, activity, me, state, &|id| { - by_id.get(&id).cloned() - }); - - let unread_count = items.iter().filter(|item| item.is_unread()).count(); - - Ok((items, unread_count)) -} - -/// `d` tag identifying the inbox state event of `me`. -fn inbox_state_d_tag(me: PublicKey) -> String { - format!("signed-inbox-state:{}", me.to_hex()) -} - -async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(inbox_state_d_tag(me)); - - let events = client.database().query(filter).await?; - - let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { - return Ok(None); - }; - - match serde_json::from_str(&event.content) { - Ok(state) => Ok(Some(state)), - Err(error) => { - log::warn!("ignoring unreadable inbox state {}: {error}", event.id); - Ok(None) - } - } -} - -/// Sign with a random key and store locally. -async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> { - let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) - .tags([Tag::identifier(inbox_state_d_tag(me))]) - .finalize(&Keys::generate())?; - - client.database().save_event(&event).await?; - - Ok(()) -} - -/// Notification events and a lookup of every ancestor they reference. -async fn fetch_notifications( - client: &Client, - me: PublicKey, - deletions: &Deletions, -) -> Result<(Vec, HashMap), Error> { - let mut notifications: Vec = Vec::new(); - let mut by_id: HashMap = HashMap::new(); - - for filter in filters::notifications(me) { - for event in client.database().query(filter).await? { - if deletions.is_deleted(&event) { - continue; - } - - if by_id.insert(event.id, event.clone()).is_none() { - notifications.push(event); - } - } - } - - let mut pending: Vec = notifications.iter().flat_map(event_references).collect(); - let mut seen: HashSet = by_id.keys().copied().collect(); - - loop { - pending.retain(|id| seen.insert(*id)); - - if pending.is_empty() { - break; - } - - let ancestors = client + let mut activity = Vec::new(); + for event in client .database() - .query(Filter::new().ids(pending.iter().copied())) - .await?; - - let mut next = Vec::new(); - - for event in ancestors { - if deletions.is_deleted(&event) { + .query(Filters::authored_activity(me)) + .await? + { + if deletions.is_deleted(&event) || !event.is_git_activity() { continue; } - next.extend(event_references(&event).filter(|id| !seen.contains(id))); - by_id.entry(event.id).or_insert(event); + by_id.entry(event.id).or_insert_with(|| event.clone()); + activity.push(event); } - pending = next; + let items = inbox::group(notification_events, activity, me, state, &|id| { + by_id.get(&id).cloned() + }); + + let unread_count = items.iter().filter(|item| item.is_unread()).count(); + + Ok((items, unread_count)) + } +} + +impl Inbox { + fn inbox_state_d_tag(me: PublicKey) -> String { + format!("signed-inbox-state:{}", me.to_hex()) } - Ok((notifications, by_id)) -} + async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(Self::inbox_state_d_tag(me)); -/// Event ids referenced by `event` through its `e` and `E` tags. -fn event_references(event: &Event) -> impl Iterator + '_ { - event.tags.iter().filter_map(|tag| { - if tag.kind() != "e" && tag.kind() != "E" { - return None; + let events = client.database().query(filter).await?; + + let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { + return Ok(None); + }; + + match serde_json::from_str(&event.content) { + Ok(state) => Ok(Some(state)), + Err(error) => { + log::warn!("ignoring unreadable inbox state {}: {error}", event.id); + Ok(None) + } } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) + } + + // Signed with a random key: the state is local-only, authorship does not + // matter and no key material needs to be kept. + async fn save_state( + client: &Client, + me: PublicKey, + state: &InboxReadState, + ) -> Result<(), Error> { + let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) + .tags([Tag::identifier(Self::inbox_state_d_tag(me))]) + .finalize(&Keys::generate())?; + + client.database().save_event(&event).await?; + + Ok(()) + } + + async fn fetch_notifications( + client: &Client, + me: PublicKey, + deletions: &Deletions, + ) -> Result<(Vec, HashMap), Error> { + let mut notifications: Vec = Vec::new(); + let mut by_id: HashMap = HashMap::new(); + + for filter in Filters::notifications(me) { + for event in client.database().query(filter).await? { + if deletions.is_deleted(&event) { + continue; + } + + if by_id.insert(event.id, event.clone()).is_none() { + notifications.push(event); + } + } + } + + let mut pending: Vec = notifications + .iter() + .flat_map(Self::event_references) + .collect(); + + let mut seen: HashSet = by_id.keys().copied().collect(); + + loop { + pending.retain(|id| seen.insert(*id)); + + if pending.is_empty() { + break; + } + + let ancestors = client + .database() + .query(Filter::new().ids(pending.iter().copied())) + .await?; + + let mut next = Vec::new(); + + for event in ancestors { + if deletions.is_deleted(&event) { + continue; + } + next.extend(Self::event_references(&event).filter(|id| !seen.contains(id))); + by_id.entry(event.id).or_insert(event); + } + + pending = next; + } + + Ok((notifications, by_id)) + } + + fn event_references(event: &Event) -> impl Iterator + '_ { + event.tags.iter().filter_map(|tag| { + if tag.kind() != "e" && tag.kind() != "E" { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 323577f..9d858ff 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,31 +1,33 @@ mod backend; +mod bootstrap; mod checkouts; mod git_store; mod inbox; mod local_repos; mod profile; +mod push; mod refresh; mod repo; mod repos; use std::path::{Path, PathBuf}; -pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; -pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; -use git_store::set_git_cache; -pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path}; +pub use backend::{Backend, BackendEvent}; +pub use bootstrap::user_grasp_list_servers; +pub use checkouts::{CheckoutStatus, CheckoutsStore}; +pub use git_store::Mirrors; use gpui::{App, AppContext}; -pub use inbox::{Inbox, query_inbox}; -pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos}; +pub use inbox::Inbox; +pub use local_repos::{LocalReposStore, ResolvedLocalRepo}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; +pub use push::{GraspServer, PushOutcome}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; -pub use repos::{RepoActivityCounts, RepoListStore}; -pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind}; -use signed_nostr::new_backend; +pub use repos::RepoListStore; +pub use signed_git::{Nip34Binding, Nip34Kind}; +use signed_nostr::NostrBackend; -#[cfg(not(target_arch = "wasm32"))] pub fn init( db_path: impl AsRef, repos_root: impl Into, @@ -35,41 +37,17 @@ pub fn init( // rustls uses the `aws_lc_rs` provider by default. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - // Initialize the nostr client and signer - let (client, signer) = cx.foreground_executor().block_on(async move { - let path = db_path.as_ref().to_path_buf(); - new_backend(path) - .await - .expect("failed to initialize nostr backend") - }); + let backend = cx + .foreground_executor() + .block_on(async move { NostrBackend::open(db_path.as_ref()).await }); + let backend = backend.expect("failed to initialize nostr backend"); + let (client, signer) = (backend.client, backend.signer); - // Set Git cache for the repos root - set_git_cache(repos_root); + Mirrors::install(repos_root); - // Set global stores for the backend Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); - - // Set global stores for the profile ProfileStore::set_global(cx.new(ProfileStore::new), cx); - - // Set global stores for the repo list and local repos RepoListStore::set_global(cx.new(RepoListStore::new), cx); - - // Set global stores for the local repos LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); - - // Set global stores for the checkouts CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); } - -/// Initialize the backend with an in-memory database on wasm. -#[cfg(target_arch = "wasm32")] -pub fn init(cx: &mut App) { - let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - set_git_cache(PathBuf::new()); - Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); - ProfileStore::set_global(cx.new(ProfileStore::new), cx); - RepoListStore::set_global(cx.new(RepoListStore::new), cx); - LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); - CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); -} diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 2a072e3..5fd5488 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -4,17 +4,15 @@ use std::sync::Arc; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; -use signed_core::{Announcement, RepoAddr, repo_addr}; +use signed_core::{Announcement, RepoAddr}; use signed_git::{LocalRepo, Nip34Binding, find_git_repos}; struct GlobalLocalReposStore(Entity); impl Global for GlobalLocalReposStore {} -/// Store of the git repositories discovered under a set of scan paths. pub struct LocalReposStore { pub roots: Arc>, - /// Git repositories discovered under [`Self::roots`], sorted by path. pub repos: Arc>, pub scanning: bool, scan_dirty: bool, @@ -45,7 +43,6 @@ impl LocalReposStore { } } - /// Forget a repository that has just been published to NIP-34. pub fn remove(&mut self, path: &Path, cx: &mut Context) { self.repos = Arc::new( self.repos @@ -94,7 +91,6 @@ impl LocalReposStore { dirty })?; - // Scans requested while this one ran are coalesced into one follow-up scan. if again { this.update(cx, |this, cx| this.rescan(cx))?; } @@ -106,28 +102,22 @@ impl LocalReposStore { } } -/// The NIP-34 coordinate a repository's detection resolved, when both the owner -/// and the identifier were recovered. pub fn local_repo_addr(repo: &LocalRepo) -> Option { let binding = repo.nip34.as_ref()?; let owner = binding.owner?; let identifier = binding.identifier.as_deref()?; - Some(repo_addr(owner, identifier)) + Some(RepoAddr::new(owner, identifier)) } -/// A scanned repository resolved against the known announcements. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedLocalRepo { pub path: PathBuf, - /// `None` for a plain repository. pub nip34: Option, - /// The known announcement this repository is bound to, when one matched. pub announcement: Option, } impl ResolvedLocalRepo { - /// The repository's directory name, or `Untitled` when the path has none. pub fn name(&self) -> SharedString { self.path .file_name() @@ -136,41 +126,42 @@ impl ResolvedLocalRepo { } } -/// Resolve the scanned repositories against the known announcements. -pub fn resolve_local_repos( - repos: &[LocalRepo], - known: &[Announcement], - own: &[Announcement], -) -> Vec { - let shown: HashSet = own.iter().map(Announcement::addr).collect(); +impl LocalReposStore { + pub fn resolve( + repos: &[LocalRepo], + known: &[Announcement], + own: &[Announcement], + ) -> Vec { + let shown: HashSet = own.iter().map(Announcement::addr).collect(); - repos - .iter() - .filter_map(|repo| { - let addr = local_repo_addr(repo); + repos + .iter() + .filter_map(|repo| { + let addr = local_repo_addr(repo); - if let Some(addr) = &addr - && shown.contains(addr) - { - return None; - } + if let Some(addr) = &addr + && shown.contains(addr) + { + return None; + } - let announcement = addr - .as_ref() - .and_then(|addr| { - known - .iter() - .find(|announcement| announcement.addr() == *addr) + let announcement = addr + .as_ref() + .and_then(|addr| { + known + .iter() + .find(|announcement| announcement.addr() == *addr) + }) + .cloned(); + + Some(ResolvedLocalRepo { + path: repo.path.clone(), + nip34: repo.nip34.clone(), + announcement, }) - .cloned(); - - Some(ResolvedLocalRepo { - path: repo.path.clone(), - nip34: repo.nip34.clone(), - announcement, }) - }) - .collect() + .collect() + } } #[cfg(test)] @@ -222,7 +213,7 @@ mod tests { let repo = bound(KEY, "mine"); let own = std::slice::from_ref(&own); - assert!(resolve_local_repos(&[repo], own, own).is_empty()); + assert!(LocalReposStore::resolve(&[repo], own, own).is_empty()); } #[test] @@ -230,61 +221,9 @@ mod tests { let known = announcement(OTHER_KEY, "theirs"); let repo = bound(OTHER_KEY, "theirs"); - let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]); + let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].announcement.as_ref(), Some(&known)); } - - #[test] - fn an_unmatched_repository_keeps_its_binding() { - let repo = bound(KEY, "unlisted"); - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].announcement.is_none()); - assert_eq!( - resolved[0].nip34.as_ref().map(|binding| binding.kind), - Some(Nip34Kind::Initialized) - ); - } - - #[test] - fn a_plain_repository_is_kept_without_a_binding() { - let repo = LocalRepo { - path: PathBuf::from("plain"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved.len(), 1); - assert!(resolved[0].nip34.is_none()); - assert!(resolved[0].announcement.is_none()); - } - - #[test] - fn the_name_is_the_directory_name() { - let repo = LocalRepo { - path: PathBuf::from("/tmp/my-repo"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("my-repo")); - } - - #[test] - fn a_path_without_a_directory_name_is_untitled() { - let repo = LocalRepo { - path: PathBuf::from("/"), - nip34: None, - }; - - let resolved = resolve_local_repos(&[repo], &[], &[]); - - assert_eq!(resolved[0].name(), SharedString::from("Untitled")); - } } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 02f84b5..0f59430 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -10,16 +10,13 @@ use gpui::{ use nostr_sdk::prelude::*; use utils::shorten_pubkey; -use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; +use crate::backend::{Backend, BackendEvent}; +use crate::bootstrap::sync_bootstrap_only; -/// How long to wait for more requests before firing a batched fetch. const BATCH_TIMEOUT: Duration = Duration::from_millis(500); -/// Max authors per profile request, keeping each filter within relay limits. const REQUEST_CHUNK: usize = 100; -/// Recent profiles prefetched at startup and read back from the cache. const WARM_LIMIT: usize = 500; -/// A user profile as plain data for the UI, from the kind-0 metadata. #[derive(Debug, Clone)] pub struct Profile { public_key: PublicKey, @@ -42,7 +39,7 @@ impl Profile { &self.metadata } - /// Display name, falling back to `name`, then a shortened npub. + // Falls back to `name`, then a shortened npub. pub fn name(&self) -> SharedString { if let Some(display_name) = self.metadata.display_name.as_ref() && !display_name.is_empty() @@ -68,14 +65,9 @@ impl Profile { } } -/// Global profile cache. -/// -/// Profiles are fetched in batches and kept as plain data. pub struct ProfileStore { profiles: HashMap, - /// Public keys requested this session, main thread only. seen: RefCell>, - /// Sender for queuing fetch requests, batched by a background task. sender: Sender, _subscription: Subscription, } @@ -125,9 +117,7 @@ impl ProfileStore { } } - /// Get a profile. - /// - /// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet. + // Returns a placeholder until fetched; queues a fetch when uncached. pub fn get(&self, public_key: &PublicKey) -> Profile { if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); @@ -178,7 +168,6 @@ impl ProfileStore { .detach(); } - /// Re-read the latest metadata of `authors` from the local database in one query. fn apply_authors(&mut self, authors: Vec, cx: &mut Context) { if authors.is_empty() { return; @@ -231,13 +220,11 @@ impl ProfileStore { .detach(); } - /// Re-read the latest metadata of every requested author from the local database. fn apply_seen(&mut self, cx: &mut Context) { let authors: Vec = self.seen.borrow().iter().copied().collect(); self.apply_authors(authors, cx); } - /// Fetch metadata for requested authors in batches, debounced to collect requests. async fn handle_requests( this: WeakEntity, client: &Client, diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs new file mode 100644 index 0000000..6858e0a --- /dev/null +++ b/crates/signed_state/src/push.rs @@ -0,0 +1,532 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::{Error, bail}; +use gpui::BackgroundExecutor; +use nostr::event::IntoEventBuilder; +use nostr::prelude::Url; +use nostr_sdk::prelude::*; +use signed_core::RepoState; +use signed_git::Repo; +use signed_nostr::UniversalSigner; + +pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3; + +const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); + +// `ws://` grasp servers, like ngit, use `http://`; secure relays map to +// `https://`. +pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { + let parsed = Url::parse(relay.as_str()).ok()?; + let host = parsed.host_str()?; + let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); + // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP. + let scheme = if relay.scheme().is_secure() { + "https" + } else { + "http" + }; + Some(format!("{scheme}://{host}{port}")) +} + +pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { + let base = grasp_base_url(relay)?; + Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() +} + +// GRASP-06 contributor namespace URL of a pull request tip. +pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { + format!("{base_url}/prs/{npub}/{repo_id}.git") +} + +// The author's GRASP-06 `/prs/` URLs come first. +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 +} + +#[derive(Debug, Clone)] +pub struct GraspServer { + relay: RelayUrl, + reason: Option, +} + +impl GraspServer { + fn ok(relay: RelayUrl) -> Self { + Self { + relay, + reason: None, + } + } + + fn failed(relay: RelayUrl, reason: impl Into) -> Self { + Self { + relay, + reason: Some(reason.into()), + } + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } +} + +#[derive(Debug, Clone, Default)] +pub struct PushOutcome { + pub servers: Vec, + // The newest state event a grasp relay accepted for this push; broadcast + // to the other relays once a git server holds the data. + pub state_event: Option, +} + +impl PushOutcome { + pub fn accepted(&self) -> usize { + self.servers + .iter() + .filter(|server| server.reason.is_none()) + .count() + } + + fn failing(&self) -> impl Iterator { + self.servers.iter().filter(|server| server.reason.is_some()) + } + + pub fn failure_summary(&self) -> String { + self.failing() + .map(|server| { + let reason = + utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error")); + format!("{}: {reason}", server.relay) + }) + .collect::>() + .join("; ") + } + + // `None` when every server accepted the push or nothing was pushed. + pub fn partial_warning(&self) -> Option { + let accepted = self.accepted(); + if self.servers.is_empty() || accepted == self.servers.len() { + return None; + } + Some(format!( + "Pushed to {accepted} of {} grasp servers: {}. Republish to sync.", + self.servers.len(), + self.failure_summary() + )) + } +} + +// Reasons a push attempt should be retried with a freshly staged state event +// and a fresh git advertisement. Two families are retried: +// +// - **Purgatory denials**: the grasp server sends these when the state event +// for the push has not reached its purgatory yet. Re-staging a fresh event +// resolves them. +// - **Stale advertisement races**: `git receive-pack` compares each ref +// update against the value it advertised when the push started. The grasp +// server's own background sync can move a ref in between - typically by +// aligning the repository to a parked state event once the objects of an +// earlier attempt land - so the compare-and-swap fails with `cannot lock +// ref` / `incorrect old value provided`. A retry against the fresh +// advertisement converges, and when the race is lost the pushed data is +// usually already on the server (see `is_stale_advertisement_race` and +// the convergence probe in `push_staged_to_grasps`). +// +// Other rejections are not retried. +fn is_transient_grasp_denial(stderr: &str) -> bool { + let error = stderr.to_lowercase(); + [ + "no state events in purgatory", + "no matching state event", + "doesn't match push", + "none from authorized publishers", + "no repository announcement found", + "cannot lock ref", + "incorrect old value provided", + ] + .iter() + .any(|marker| error.contains(marker)) +} + +// A push rejected because `git receive-pack`'s compare-and-swap lost to the +// grasp server's own background ref alignment: the ref moved between this +// push's advertisement and its ref transaction (`cannot lock ref ... is at +// ... but expected ...` / `incorrect old value provided`). The pushed data +// is usually already on the server by then. +fn is_stale_advertisement_race(stderr: &str) -> bool { + let error = stderr.to_lowercase(); + error.contains("cannot lock ref") || error.contains("incorrect old value provided") +} + +// All staged events carry the same refs; the newest timestamp wins on the relays. +fn keep_newest(state_event: &mut Option, event: Event) { + if state_event + .as_ref() + .is_none_or(|current| event.created_at > current.created_at) + { + *state_event = Some(event); + } +} + +// The grasp push pipeline: stage a signed state event on each server's +// relay, then push the git data, retrying transient denials. +#[derive(Clone)] +pub(crate) struct GraspPush { + client: Client, + signer: UniversalSigner, +} + +impl GraspPush { + pub(crate) fn new(client: Client, signer: UniversalSigner) -> Self { + Self { client, signer } + } + + pub(crate) fn require_relay_accepted( + output: SendEventOutput, + event: Event, + ) -> Result { + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + + Ok(event) + } + + // A relay hiccup should not block sign-up, so failures are only logged. + pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) { + let result: Result<(), Error> = async { + let event = builder.finalize_async(&self.signer).await?; + let output = self.client.send_event(&event).broadcast().await?; + Self::require_relay_accepted(output, event)?; + Ok(()) + } + .await; + + if let Err(e) = result { + log::warn!("failed to publish identity bootstrap event: {e}"); + } + } + + pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result { + let event = builder.finalize_async(&self.signer).await?; + self.send_accepted(event).await + } + + pub(crate) async fn send_accepted(&self, event: Event) -> Result { + let output = self.client.send_event(&event).broadcast().await?; + Self::require_relay_accepted(output, event) + } + + pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> { + let builder = EventDeletionRequest::new() + .id(event.id) + .into_event_builder(); + + let deletion = builder.finalize_async(&self.signer).await?; + self.client.send_event(&deletion).broadcast().await?; + + Ok(()) + } + + // Retries within the same second get the next second: a grasp relay + // treats a same-id resend as a duplicate and does not re-run its ingest, + // so an identical resend cannot re-park a state event lost from its purgatory. + async fn sign_state_event( + &self, + repo_id: &str, + refs: &[(String, String)], + head: Option<&str>, + last_created_at: u64, + ) -> Result<(Event, u64), String> { + let now = Timestamp::now().as_secs(); + let created_at = if now > last_created_at { + now + } else { + last_created_at + 1 + }; + + let event = RepoState::build(repo_id, refs, head) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize_async(&self.signer) + .await + .map_err(|e| format!("could not sign the state event: {e}"))?; + + Ok((event, created_at)) + } + + // `Ok` only when the relay confirmed the event. On a grasp relay the + // accept parks the event in purgatory, which authorizes the paired git push. + async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> { + self.client + .add_relay(relay) + .and_connect() + .await + .map_err(|e| format!("could not add relay {relay}: {e}"))?; + + let output = self + .client + .send_event(event) + .to([relay.clone()]) + .await + .map_err(|e| format!("could not send the state event to {relay}: {e}"))?; + + if output.success.contains_key(relay) { + Ok(()) + } else { + let reason = output + .failed + .get(relay) + .cloned() + .unwrap_or_else(|| "relay did not confirm the event".to_owned()); + Err(reason) + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn push_staged_to_grasps( + &self, + repo_id: &str, + refs: &[(String, String)], + head: Option<&str>, + path: &Path, + owner: &str, + servers: &[RelayUrl], + executor: &BackgroundExecutor, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, + ) -> PushOutcome { + let mut outcome = PushOutcome::default(); + + for relay in servers { + let Some(base) = grasp_base_url(relay) else { + outcome + .servers + .push(GraspServer::failed(relay.clone(), "no domain")); + continue; + }; + let git_url = format!("{base}/{owner}/{repo_id}.git"); + + let mut reason = None; + let mut last_created_at = 0; + // For the convergence probe when every push attempt lost the + // stale-ref race. + let mut staged_event = None; + + 'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS { + if attempt > 1 { + executor.timer(GRASP_RETRY_DELAY).await; + } + + let (event, created_at) = match self + .sign_state_event(repo_id, refs, head, last_created_at) + .await + { + Ok(signed) => signed, + Err(e) => { + reason = Some(e); + break 'server; + } + }; + + last_created_at = created_at; + + // A failed stage means the grasp never parked the state, so + // the git push would be denied anyway: skip it. + if let Err(e) = self.stage_event_on_relay(relay, &event).await { + // One retry absorbs a relay connect blip, on the first + // attempt only. + if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() { + } else { + reason = Some(e); + break 'server; + } + } + staged_event = Some(event.clone()); + + match push(path, &base, owner, repo_id) { + Ok(()) => { + keep_newest(&mut outcome.state_event, event); + break 'server; + } + Err(e) => { + let text = e.to_string(); + if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) { + reason = Some(text); + continue 'server; + } + reason = Some(text); + break 'server; + } + } + } + + // The grasp's own background sync aligns refs to staged state + // events as soon as the objects land, which can beat every push + // attempt's compare-and-swap. When the last denial was that race + // the sync has usually finished by now: verify the advertised refs + // and accept the server when the pushed data is already there. + if let Some(last_reason) = &reason + && is_stale_advertisement_race(last_reason) + && Repo::open(path) + .and_then(|repo| repo.remote_has_refs(&git_url, refs)) + .unwrap_or(false) + { + if let Some(event) = staged_event { + keep_newest(&mut outcome.state_event, event); + } + reason = None; + } + + match reason { + Some(reason) => { + log::warn!("grasp push failed: {relay}: {reason}"); + outcome + .servers + .push(GraspServer::failed(relay.clone(), reason)); + } + None => outcome.servers.push(GraspServer::ok(relay.clone())), + } + } + + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grasp_base_url_maps_schemes_like_ngit() { + let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); + assert_eq!( + grasp_base_url(&wss).as_deref(), + Some("https://relay.ngit.dev") + ); + + let ws = RelayUrl::parse("ws://localhost:8080").expect("url"); + assert_eq!( + grasp_base_url(&ws).as_deref(), + Some("http://localhost:8080") + ); + } + + #[test] + fn grasp_clone_url_matches_ngit_format() { + let relay = RelayUrl::parse("wss://gitnostr.com").expect("url"); + let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url"); + assert_eq!( + url.to_string(), + "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" + ); + 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", + ] + ); + } + + #[test] + fn transient_grasp_denials_are_classified() { + let reported = "remote: ERR authorisation failed: No state events in purgatory\n\ + fatal: the remote end hung up unexpectedly\n\ + error: failed to push some refs to 'https://relay.ngit.dev/...git'"; + assert!(is_transient_grasp_denial(reported)); + + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: No matching state event found in purgatory" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: 1 state event in purgatory from authorized \ + publisher but doesn't match push" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: 2 state events in purgatory but none from \ + authorized publishers" + )); + assert!(is_transient_grasp_denial( + "remote: ERR authorisation failed: No repository announcement found" + )); + + assert!(!is_transient_grasp_denial( + "remote: ERR authorisation failed: not a maintainer of this repository" + )); + assert!(!is_transient_grasp_denial( + "fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \ + error: 403" + )); + assert!(!is_transient_grasp_denial( + "fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host" + )); + } + + #[test] + fn stale_ref_races_are_retried() { + let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \ + cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \ + bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\ + To https://relay.ngit.dev/npub1owner/signed-test.git\n\ + ! [remote rejected] main -> main (incorrect old value provided)\n\ + error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'"; + assert!(is_transient_grasp_denial(reported)); + assert!(is_stale_advertisement_race(reported)); + + assert!(is_stale_advertisement_race( + "cannot lock ref 'refs/heads/main'" + )); + assert!(is_stale_advertisement_race( + "! [remote rejected] main -> main (incorrect old value provided)" + )); + + assert!(!is_stale_advertisement_race("No state events in purgatory")); + + assert!(!is_transient_grasp_denial( + " ! [rejected] main -> main (non-fast-forward)" + )); + } +} diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index 695cb7c..0f63e56 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -1,4 +1,3 @@ -/// Refresh coalescing shared by the event stores. #[derive(Debug, Default)] pub struct RefreshGate { running: bool, @@ -7,9 +6,7 @@ pub struct RefreshGate { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshRequest { - /// No run covers the request, start one now. Schedule, - /// A run is in flight and covers the request, fold it into a follow-up. Fold, } @@ -31,59 +28,13 @@ impl RefreshGate { self.running = true; } - /// The run ended. Whether a request arrived while it ran. pub fn finish(&mut self) -> bool { self.running = false; std::mem::take(&mut self.dirty) } - /// The run was abandoned, e.g. on error. Pending follow-up requests survive. + // Pending follow-up requests survive an abandoned run. pub fn abort(&mut self) { self.running = false; } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_request_while_running_folds_into_a_follow_up() { - let mut gate = RefreshGate::default(); - gate.begin(); - - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - } - - #[test] - fn a_request_without_a_run_schedules() { - let mut gate = RefreshGate::default(); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - assert!(!gate.running()); - } - - #[test] - fn a_request_after_a_run_schedules_again() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - assert!(gate.finish()); - - assert_eq!(gate.request(), RefreshRequest::Schedule); - } - - #[test] - fn abort_keeps_the_pending_request() { - let mut gate = RefreshGate::default(); - gate.begin(); - assert_eq!(gate.request(), RefreshRequest::Fold); - - gate.abort(); - assert!(!gate.running()); - - gate.begin(); - assert!(gate.finish()); - } -} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ffd9623..fd4f907 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,88 +9,53 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, - pull_request_patches, + Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, + filters, }; -use signed_git::Nip34Binding; +use signed_git::{Nip34Binding, PatchParser, Repo}; use signed_nostr::UniversalSigner; -use crate::backend::{ - Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted, - user_grasp_list_servers, -}; +use crate::backend::{Backend, BackendEvent}; +use crate::bootstrap::user_grasp_list_servers; use crate::checkouts::CheckoutsStore; -use crate::git_store::ensure_repo_mirror; +use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; -/// Maximum size of one patch event. -/// -/// NIP-34 suggests patches when each event is under 60kb. +// NIP-34 suggests patches when each event is under 60kb. const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; -/// Per-repository store. -/// -/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. pub struct RepoStore { - /// NIP-34 address. `None` while the repository is local-only. addr: Option, - /// Latest announcement. Seeded from the open-time hint, replaced by the - /// database's latest on the first pass. `None` while local-only. + // Seeded from the open-time hint, replaced by the database's latest on the + // first pass. `None` while local-only. pub announcement: Option, - /// Local working copy. The scan path for a local repository, kept when it is - /// later announced so the panel keeps its worktree. + // The scan path for a local repository, kept when it is later announced so + // the panel keeps its worktree. pub path: Option, - /// NIP-34 state detected on disk for a local repository, if any. pub nip34: Option, - /// The first local pass has been applied. - /// - /// Views distinguish "no data yet" from a genuinely empty repository with it. + // Views distinguish "no data yet" from a genuinely empty repository with it. pub loaded: bool, - /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, pub patches: Vec, pub pull_requests: Vec, - /// Comments on issues / PRs, oldest first. pub comments: Vec, - /// Resolved status per root event, issue, patch or PR. status_by_root: HashMap, - /// Open issue and root PR counts. - /// Computed with [`Self::status_by_root`] on every refresh. open_issue_count: usize, open_pr_count: usize, pub last_error: Option, - /// Non-fatal warning of the last action, if any. - /// - /// Example, a PR published without its commit reaching a grasp server. pub last_warning: Option, - /// Warning of the last push that only some grasp servers accepted. - /// - /// The repository is out of sync on the rejected servers until it is republished. + // The repository is out of sync on the rejected servers until republished. pub last_push_warning: Option, - /// A republish or a checkout push is in flight. - /// - /// Views show a spinner and disable their push triggers while it is set. pub pushing: bool, - /// A clone-into-a-folder operation is in flight. - /// - /// Views show a spinner and disable the clone trigger while it is set. pub cloning: bool, - /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag. - /// - /// Avoids re-subscribing and re-fetching on every refresh. + // Avoids re-subscribing and re-fetching on every refresh. repo_relays: HashSet, - /// Root events, issues, patches and PRs, already fetched per root. - /// - /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. + // Covers NIP-22 comments and statuses without an `a` tag. root_fetches: HashSet, - /// Maintainers already synced through gossip in Uncensored mode. - /// - /// Avoids re-running the maintainer Auto sync on every refresh. synced_maintainers: HashSet, - /// In-flight tasks, cancelled when the store drops. + // In-flight tasks, cancelled when the store drops. tasks: Vec>>, - /// Backend subscription of an announced repository. `None` while local-only. _subscription: Option, } @@ -143,7 +108,6 @@ impl RepoStore { } } - /// Local repository discovered by the scan, not announced to NIP-34 yet. pub fn new_local(path: PathBuf, nip34: Option) -> Self { Self { addr: None, @@ -172,7 +136,6 @@ impl RepoStore { } } - /// An announced repository whose working copy is already on disk. pub fn from_worktree( addr: RepoAddr, announcement: Announcement, @@ -184,7 +147,6 @@ impl RepoStore { store } - /// Switch a local repository to its NIP-34 mode, keeping its path. pub fn announce(&mut self, announcement: Announcement, cx: &mut Context) { self.addr = Some(announcement.addr()); self.announcement = Some(announcement.clone()); @@ -212,8 +174,8 @@ impl RepoStore { let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(addr); - let author = update.author == addr.public_key; + let coordinate = update.coordinate.as_ref() == Some(addr.coordinate()); + let author = update.author == addr.public_key(); let authored = (update.kind == Kind::GitRepoAnnouncement || update.kind == Kind::RepoState) @@ -237,7 +199,6 @@ impl RepoStore { self.addr.as_ref() } - /// Returns the repository's name, or `Unknown` when not known. pub fn name(&self) -> SharedString { self.announcement .as_ref() @@ -246,23 +207,19 @@ impl RepoStore { }) } - /// Filters that make up a repository. - /// - /// Announcement, state, activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) - .author(addr.public_key) - .identifier(addr.identifier.clone()), - filters::activity(addr), + .author(addr.public_key()) + .identifier(addr.identifier()), + addr.activity_filter(), ]; - // Deletion requests, NIP-09/62, must be known before any event is shown. - filters.extend(filters::deletions_for_repo(addr)); + // Deletion requests must be known before any event is shown. + filters.extend(addr.deletion_filters()); filters } - /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { let Some(addr) = self.addr.clone() else { return; @@ -287,39 +244,33 @@ impl RepoStore { }); } - /// Filters the SDK resolves through NIP-65 gossip in Uncensored mode. + // NIP-34 events tag the announcement author, which may not be a + // maintainer for subordinate forks. fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec { let mut pubkeys = maintainers.to_vec(); - // NIP-34 events tag the announcement author, - // which may not be a maintainer for subordinate forks. - if !pubkeys.contains(&addr.public_key) { - pubkeys.push(addr.public_key); + if !pubkeys.contains(&addr.public_key()) { + pubkeys.push(addr.public_key()); } vec![ - // Announcement and state events, including co-maintainer states. Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .authors(pubkeys.clone()) - .identifier(addr.identifier.clone()), - // Activity tagging a maintainer, resolved to their read relays. + .identifier(addr.identifier()), Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .pubkeys(pubkeys.clone()), - // Activity authored by a maintainer, resolved to their write relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .authors(pubkeys.clone()), - // Deletions authored by a maintainer. Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) .authors(pubkeys), ] } - /// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context) { let strategy = SettingsStore::try_global(cx) .map(|store| store.read(cx).settings().event_fetching) @@ -360,7 +311,6 @@ impl RepoStore { }); } - /// Re-query the local database and update all fields. pub fn refresh(&mut self, cx: &mut Context) { if self.addr.is_none() { return; @@ -379,10 +329,10 @@ impl RepoStore { let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; - let states = db.query(filters::state(&addr)).await?; - let activity = db.query(filters::activity(&addr)).await?; - let deletion_events = db.query(filters::deletions()).await?; + let announcements = db.query(addr.announcement_filter()).await?; + let states = db.query(addr.state_filter()).await?; + let activity = db.query(addr.activity_filter()).await?; + let deletion_events = db.query(Filters::deletions()).await?; Ok::<_, Error>((announcements, states, activity, deletion_events)) } @@ -394,12 +344,12 @@ impl RepoStore { .into_iter() .filter(|e| !deletions.is_deleted(e)); - let announcement = latest(all_announcements) + let announcement = utils::latest(all_announcements) .as_ref() .and_then(Announcement::from_event); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); - let state = latest(all_states).map(|state| parse_state(&state)); + let state = utils::latest(all_states).map(|state| RepoState::parse(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); @@ -427,7 +377,7 @@ impl RepoStore { .chain(&pull_requests) .map(|e| e.id); - for filter in filters::comments_for(roots) { + for filter in Filters::comments_for(roots) { for event in db.query(filter).await? { if seen_comments.insert(event.id) { comments.push(event); @@ -445,17 +395,17 @@ impl RepoStore { .map(|e| e.id); for root in roots { - for event in db.query(filters::statuses_for([root])).await? { + for event in db.query(Filters::statuses_for([root])).await? { if seen_statuses.insert(event.id) { statuses.push(event); } } } - sort_newest_first(&mut issues); - sort_newest_first(&mut patches); - sort_newest_first(&mut pull_requests); - sort_oldest_first(&mut comments); + utils::sort_newest_first(&mut issues); + utils::sort_newest_first(&mut patches); + utils::sort_newest_first(&mut pull_requests); + utils::sort_oldest_first(&mut comments); let maintainers = announcement .as_ref() @@ -518,7 +468,7 @@ impl RepoStore { let head_changed = state .as_ref() - .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + .is_some_and(|state| this.head.as_deref() != state.head.as_deref()); let changed = first_pass || (!keep_hint && this.announcement != announcement) @@ -533,7 +483,6 @@ impl RepoStore { this.announcement = announcement; } - // The announcement may list relays for this repository's activity. let relays = this .announcement .as_ref() @@ -542,7 +491,6 @@ impl RepoStore { this.connect_announced_relays(&relays, cx); - // Uncensored mode also covers the maintainers' NIP-65 relays. let maintainers = this .announcement .as_ref() @@ -551,8 +499,8 @@ impl RepoStore { this.sync_maintainer_relays(&maintainers, cx); - if let Some((_, head)) = state { - this.head = head; + if let Some(state) = state { + this.head = state.head; } this.issues = issues; @@ -581,8 +529,8 @@ impl RepoStore { if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); - let mut root_filters = filters::comments_for(new_roots.clone()); - root_filters.push(filters::statuses_for(new_roots.iter().copied())); + let mut root_filters = Filters::comments_for(new_roots.clone()); + root_filters.push(Filters::statuses_for(new_roots.iter().copied())); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); @@ -604,26 +552,22 @@ impl RepoStore { self.tasks.push(task); } - /// Resolve the status of a root event, an issue, patch or PR, per NIP-34. pub fn status_of(&self, root: &Event) -> RepoStatus { status_of(&self.status_by_root, root) } - /// Number of open issues. pub fn issue_count(&self) -> usize { self.open_issue_count } - /// Number of open pull requests. pub fn pull_request_count(&self) -> usize { self.open_pr_count } - /// Whether `user` is the author or owner of this repository. pub fn is_author(&self, user: &PublicKey) -> bool { self.addr .as_ref() - .is_some_and(|addr| &addr.public_key == user) + .is_some_and(|addr| addr.public_key() == *user) } pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { @@ -633,7 +577,7 @@ impl RepoStore { }; let builder = GitIssue { - repository: addr, + repository: addr.into(), content, subject, labels: Vec::new(), @@ -646,18 +590,14 @@ impl RepoStore { pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() - .filter(move |e| signed_core::references_root(e, root)) + .filter(move |e| e.references_root(root)) } - /// Comment on a root event, an issue or PR, per NIP-34, kind 1111. pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { self.reply(root, None, content, cx); } - /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. - /// - /// `None` publishes a top-level comment on the root itself. - pub fn reply( + fn reply( &mut self, root: &Event, parent: Option<&Event>, @@ -701,31 +641,20 @@ impl RepoStore { return; }; - let series: Vec = signed_git::split_patch_series(&patch) - .into_iter() - .map(str::to_owned) - .collect(); + let series = PatchSeries::parse(&patch); - if let Some(oversized) = series - .iter() - .find(|part| part.len() > MAX_PATCH_EVENT_BYTES) - { + if let Some(oversized) = series.oversized_length() { self.last_error = Some(format!( "patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)", - oversized.len(), - MAX_PATCH_EVENT_BYTES + oversized, MAX_PATCH_EVENT_BYTES )); cx.notify(); return; } - // The tip of the series is its last commit. - // `git format-patch` orders patches oldest first. - let Some(current_commit) = series - .last() - .and_then(|part| patch_current_commit(part)) - .and_then(|hex| hex.parse::().ok()) - else { + // The tip of the series is its last commit; `git format-patch` orders + // patches oldest first. + let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), ); @@ -744,12 +673,11 @@ impl RepoStore { cx.notify(); return; }; - // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let repo_id = addr.identifier.clone(); + let repo_id = addr.identifier().to_owned(); let base_npub = owner.to_bech32().unwrap(); let push_relays = self .announcement @@ -775,8 +703,6 @@ impl RepoStore { }; let task: Task> = cx.spawn(async move |this, cx| { - // The PR references the root patch, - // viewers can then find the patch without carrying it inline. let root_patch = match publish_patch_series( &client, &signer, @@ -798,11 +724,10 @@ impl RepoStore { } }; - // GRASP-06 pushes the tip to the author's own grasp servers. - // The path is `/prs//.git`. - // Contributing to another project never depends on that project's servers. - // Resolve the servers from the author's latest kind-10317 grasp list. - // The settings defaults stand in when no list is published or the query fails. + // GRASP-06 pushes the tip to the author's own grasp servers at + // `/prs//.git`; contributing to another + // project never depends on that project's servers. Resolve the + // servers from the author's latest kind-10317 grasp list. let author_servers = { let query_client = client.clone(); let published = cx @@ -859,7 +784,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: addr.clone(), + repository: addr.clone().into(), content: description, subject, labels: Vec::new(), @@ -879,7 +804,6 @@ impl RepoStore { } })?; - // Sign before publishing. let event = cx .background_spawn({ let signer = signer.clone(); @@ -896,7 +820,6 @@ impl RepoStore { let path = path.clone(); let tip = tip.clone(); let reference = reference.clone(); - // Author servers first, then the announced base grasp servers. let targets: Vec<(String, String)> = author_targets .into_iter() .chain(base_targets) @@ -907,9 +830,9 @@ impl RepoStore { let mut pushed = 0; for (url, label) in &targets { - match signed_git::push_commit_ref( - &path, url, &tip, &reference, - ) { + match Repo::open(&path) + .and_then(|repo| repo.push_ref(url, &tip, &reference)) + { Ok(()) => pushed += 1, Err(e) => failures.push(format!("{label}: {e}")), } @@ -931,11 +854,10 @@ impl RepoStore { } } - let publish_result: Result = async { - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let publish_result = { + let pusher = GraspPush::new(client.clone(), signer.clone()); + pusher.send_accepted(event).await + }; let pr_event = match publish_result { Ok(event) => event, @@ -947,8 +869,6 @@ impl RepoStore { } }; - // A draft PR carries a kind-1633 status event, NIP-34. - // Publish it right after the PR event so viewers never show it open. if draft { this.update(cx, |this, cx| { this.set_status(&pr_event, RepoStatus::Draft, cx); @@ -960,12 +880,6 @@ impl RepoStore { self.tasks.push(task); } - /// Generate the patch between `merge_base` and `compare_ref` in `repo_path`, - /// then open a pull request from it. - /// - /// Fails descriptively when there are no commits to propose or the patch - /// could not be generated; otherwise publishes exactly like - /// [`Self::open_pull_request`]. #[allow(clippy::too_many_arguments)] pub fn open_pull_request_from_refs( &mut self, @@ -979,15 +893,15 @@ impl RepoStore { cx: &mut Context, ) -> Task> { cx.spawn(async move |this, cx| { - // Regenerate the series at submit time. - // The published patch covers the current tip of the compare branch. + // Regenerate 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 compare_ref = compare_ref.clone(); async move { - signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref) + Repo::open(&repo_path)?.format_patch_between(&merge_base, &compare_ref) } }) .await; @@ -1013,9 +927,6 @@ impl RepoStore { }) } - /// Update a pull request. - /// - /// Other authors must open a new PR. pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -1038,29 +949,18 @@ impl RepoStore { return; } - let series: Vec = signed_git::split_patch_series(&patch) - .into_iter() - .map(str::to_owned) - .collect(); - if let Some(oversized) = series - .iter() - .find(|part| part.len() > MAX_PATCH_EVENT_BYTES) - { + let series = PatchSeries::parse(&patch); + if let Some(oversized) = series.oversized_length() { self.last_error = Some(format!( "patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)", - oversized.len(), - MAX_PATCH_EVENT_BYTES + oversized, MAX_PATCH_EVENT_BYTES )); cx.notify(); return; } - // The new tip of the PR is the last commit of the series. - let Some(current_commit) = series - .last() - .and_then(|part| patch_current_commit(part)) - .and_then(|hex| hex.parse::().ok()) - else { + // The tip of the updated PR is the last commit of the series. + let Some(current_commit) = series.tip_commit() else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), ); @@ -1068,10 +968,11 @@ impl RepoStore { return; }; - // The first revision patch replies to the original root patch, NIP-34. - // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none. + // The first revision patch replies to the original root patch, NIP-34: + // use the PR's `e` tag, or the oldest patch of the linked set. let root_patch_id = root.tags.event_ids().next().or_else(|| { - pull_request_patches(root, self.patches.iter()) + PullRequest::new(root) + .patches(self.patches.iter()) .first() .map(|p| p.id) }); @@ -1081,7 +982,7 @@ impl RepoStore { return; }; - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); @@ -1112,7 +1013,7 @@ impl RepoStore { let builder = { let builder = GitPullRequestUpdate { - repository: addr.clone(), + repository: addr.clone().into(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1121,20 +1022,18 @@ impl RepoStore { } .into_event_builder(); - // The `r` EUC tag lets clients subscribe to all PR updates. - // The SDK builder omits it. + // The `r` EUC tag lets clients subscribe to all PR updates; the + // SDK builder omits it. match euc.as_deref() { Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), None => builder, } }; - let publish_result: Result = async { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let publish_result = { + let pusher = GraspPush::new(client.clone(), signer.clone()); + pusher.publish_one(builder).await + }; if let Err(e) = publish_result { return this.update(cx, |this, cx| { @@ -1148,10 +1047,7 @@ impl RepoStore { self.tasks.push(task); } - /// Set the status of a root event. - /// - /// Only the root author or a maintainer may set it, per NIP-34. - pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { + fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; let Some(addr) = self.addr.clone() else { @@ -1184,98 +1080,14 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(addr.public_key), + Tag::public_key(addr.public_key()), Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), + Tag::coordinate(addr.clone().into(), None), ]); self.publish(builder, cx); } - pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { - self.last_error = None; - self.last_warning = None; - - let Some(addr) = self.addr.clone() else { - self.not_announced(cx); - return; - }; - - let is_author = Backend::global(cx) - .read(cx) - .current_user() - .is_some_and(|user| self.is_author(&user)); - if !is_author { - self.last_error = Some("Only the repository author can merge pull requests".into()); - return; - } - - let clone_urls: Vec = self - .announcement - .as_ref() - .map(|a| a.clone.clone()) - .unwrap_or_default(); - - let patch = pull_request_patch(root, self.patches.iter()); - - // The applied patch events, for the status tags below. - let patches: Vec = pull_request_patches(root, self.patches.iter()) - .into_iter() - .cloned() - .collect(); - - let relay_hint = self - .announcement - .as_ref() - .and_then(|a| a.relays.first()) - .map(ToString::to_string) - .unwrap_or_default(); - - let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let root = root.clone(); - - let apply = cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - // The commits the apply created. - // Everything between the previous HEAD and the new one, oldest first. - let previous = signed_git::head_commit_id(&workdir)?; - signed_git::apply_patch(&workdir, &patch)?; - let applied = signed_git::commits_since(&workdir, previous.as_deref())?; - Ok::<_, Error>(applied) - }); - - let task: Task> = cx.spawn(async move |this, cx| { - match apply.await { - Ok(applied) => { - this.update(cx, |this, cx| { - this.publish_applied_status( - &root, - &patches, - &applied, - &relay_hint, - euc.as_deref(), - cx, - ); - })?; - } - Err(e) => { - this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - })?; - } - } - Ok(()) - }); - self.tasks.push(task); - } - - /// The latest announcement of this repository, - /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { let addr = self.addr.as_ref()?; self.announcement.clone().or_else(|| { @@ -1298,38 +1110,10 @@ impl RepoStore { return self.action_error("Repository announcement is not loaded yet", cx); }; - self.pushing = true; - self.last_error = None; - self.last_push_warning = None; - cx.notify(); - let backend = Backend::global(cx); let push = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx)); - cx.spawn(async move |this, cx| { - let result = push.await; - - this.update(cx, |this, cx| { - this.pushing = false; - - match &result { - Ok(outcome) => { - this.last_error = None; - // A push only some grasp servers accepted is a warning: - // the repo is out of sync on the rest until it is republished. - this.last_push_warning = outcome.partial_warning(); - } - Err(e) => { - this.last_error = Some(format!("Push failed: {e}")); - this.last_push_warning = None; - } - } - - cx.notify(); - })?; - - result.map(|_| ()) - }) + self.run_push(push, None, cx) } pub fn push_checkout( @@ -1355,17 +1139,25 @@ impl RepoStore { // The checkout may be on a side branch. let head = self.head.clone(); - self.pushing = true; - self.last_error = None; - self.last_push_warning = None; - cx.notify(); - - let checkouts = CheckoutsStore::global(cx); let backend = Backend::global(cx); let push = backend.update(cx, |backend, cx| { backend.push_checkout(announcement, path.clone(), head, cx) }); + self.run_push(push, Some((addr, path)), cx) + } + + fn run_push( + &mut self, + push: Task>, + pushed_checkout: Option<(RepoAddr, PathBuf)>, + cx: &mut Context, + ) -> Task> { + self.pushing = true; + self.last_error = None; + self.last_push_warning = None; + cx.notify(); + cx.spawn(async move |this, cx| { let result = push.await; @@ -1375,13 +1167,12 @@ impl RepoStore { match &result { Ok(outcome) => { this.last_error = None; - // A push only some grasp servers accepted is a warning: - // the repo is out of sync on the rest until it is republished. this.last_push_warning = outcome.partial_warning(); - // The remote moved, so recompute the ready-to-push statuses. - checkouts.update(cx, |store, cx| { - store.checkout_pushed(&addr, &path, cx); - }); + if let Some((addr, path)) = &pushed_checkout { + CheckoutsStore::global(cx).update(cx, |store, cx| { + store.checkout_pushed(addr, path, cx); + }); + } } Err(e) => { this.last_error = Some(format!("Push failed: {e}")); @@ -1396,10 +1187,6 @@ impl RepoStore { }) } - /// Delete the repository from nostr, announcement, state and activity. - /// - /// Only the repository owner may delete it. The lists update when the - /// deletion events arrive. pub fn delete_repository(&mut self, cx: &mut Context) -> Task> { let Some(addr) = self.addr.clone() else { return self.action_error("This repository is not published to Nostr yet", cx); @@ -1421,8 +1208,7 @@ impl RepoStore { }) } - /// Clone the repository into `destination`, a user-chosen folder outside - /// the cache, and remember the clone as a checkout of this repository. + // A user-chosen folder outside the cache; remembered as a checkout. pub fn clone_to_folder( &mut self, destination: PathBuf, @@ -1450,7 +1236,8 @@ impl RepoStore { let clone = { let destination = destination.clone(); - cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) + // gix handles are not `Send` and must not cross the spawn boundary. + cx.background_spawn(async move { Repo::clone(&clone_urls, &destination).map(|_| ()) }) }; cx.spawn(async move |this, cx| { @@ -1495,72 +1282,8 @@ impl RepoStore { Task::ready(Err(anyhow::anyhow!("{message}"))) } - /// Publish a kind-1631 Applied status event for `root` after a merge. - fn publish_applied_status( - &mut self, - root: &Event, - patches: &[Event], - applied: &[String], - relay_hint: &str, - euc: Option<&str>, - cx: &mut Context, - ) { - let Some(addr) = self.addr.clone() else { - return; - }; - - let mut tags = vec![ - Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(addr.public_key), - Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), - ]; - - if let Some(euc) = euc - && let Ok(tag) = Tag::parse(["r", euc]) - { - tags.push(tag); - } - - // Tag each applied patch event. - // `q` per event, `e` reply for events beyond the root, chain parts and revisions. - // Their statuses then resolve to Applied too. - for (ix, patch) in patches.iter().enumerate() { - if let Ok(tag) = - Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) - { - tags.push(tag); - } - if ix > 0 - && let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"]) - { - tags.push(tag); - } - } - - // The commits `git am` created on top of the previous HEAD. - if !applied.is_empty() { - let mut applied_tag = vec!["applied-as-commits".to_string()]; - applied_tag.extend(applied.iter().cloned()); - if let Ok(tag) = Tag::parse(applied_tag) { - tags.push(tag); - } - for commit in applied { - if let Ok(tag) = Tag::parse(["r", commit]) { - tags.push(tag); - } - } - } - - self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx); - } - - /// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`]. - /// - /// Every one-shot repository event (issue, comment, status) goes through - /// this. Multi-step flows (opening or updating a pull request, a patch - /// series) call the SDK directly instead, since their error handling and - /// post-conditions differ per step. + // Every one-shot repository event (issue, comment, status) goes through + // this; multi-step flows call the SDK directly instead. fn publish(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; @@ -1571,12 +1294,8 @@ impl RepoStore { }; let task: Task> = cx.spawn(async move |this, cx| { - let publish_result: Result = async { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) - } - .await; + let pusher = GraspPush::new(client, signer); + let publish_result = pusher.publish_one(builder).await; if let Err(e) = publish_result { this.update(cx, |this, cx| { @@ -1591,13 +1310,6 @@ impl RepoStore { } } -fn latest(events: I) -> Option -where - I: IntoIterator, -{ - events.into_iter().max_by_key(|e| e.created_at) -} - fn status_of(status_by_root: &HashMap, root: &Event) -> RepoStatus { status_by_root .get(&root.id) @@ -1630,31 +1342,59 @@ fn resolve_statuses( .map(|root| { let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]); let status = - signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); + signed_core::RepoStatus::resolve(events.iter().copied(), &root.pubkey, maintainers); (root.id, status) }) .collect() } -fn sort_newest_first(events: &mut [Event]) { - events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); -} - -fn sort_oldest_first(events: &mut [Event]) { - events.sort_by_key(|e| e.created_at); -} - -/// The proposed commit of a `git format-patch` output. -/// It is the `From ` header on the first line. +// The `From ` header on the first line. fn patch_current_commit(patch: &str) -> Option<&str> { let line = patch.lines().next()?; let hex = line.strip_prefix("From ")?; hex.split_whitespace().next().filter(|hex| hex.len() == 40) } -/// Publish a `git format-patch` series as chained kind-1617 events. -/// -/// Returns the root event, the one a PR references. +struct PatchSeries { + parts: Vec, +} + +impl PatchSeries { + fn parse(patch: &str) -> Self { + Self { + parts: PatchParser::split_patch_series(patch) + .into_iter() + .map(str::to_owned) + .collect(), + } + } + + // The byte length of the first part over the NIP-34 size suggestion. + fn oversized_length(&self) -> Option { + self.parts + .iter() + .map(String::len) + .find(|length| *length > MAX_PATCH_EVENT_BYTES) + } + + // The tip of the series is its last commit; `git format-patch` orders + // patches oldest first. + fn tip_commit(&self) -> Option { + self.parts + .last() + .and_then(|part| patch_current_commit(part)) + .and_then(|hex| hex.parse::().ok()) + } + + fn commit_of(&self, index: usize) -> Option<&str> { + self.parts + .get(index) + .and_then(|part| patch_current_commit(part)) + .filter(|hex| hex.len() == 40) + } +} + +// Returns the root event, the one a PR references. #[allow(clippy::too_many_arguments)] async fn publish_patch_series( client: &Client, @@ -1662,22 +1402,26 @@ async fn publish_patch_series( addr: &RepoAddr, owner: PublicKey, euc: Option<&str>, - series: &[String], + series: &PatchSeries, first_marker: &str, reply_to: Option, ) -> Result { + let pusher = GraspPush::new(client.clone(), signer.clone()); let mut root: Option = None; let mut previous = reply_to; - for (ix, part) in series.iter().enumerate() { - let Some(commit) = patch_current_commit(part).filter(|hex| hex.len() == 40) else { + for (ix, part) in series.parts.iter().enumerate() { + let Some(commit) = series.commit_of(ix) else { return Err(anyhow::anyhow!( "patch {} of the series has no `From ` header", ix + 1 )); }; - let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)]; + let mut tags = vec![ + Tag::coordinate(addr.clone().into(), None), + Tag::public_key(owner), + ]; if ix == 0 { if let Ok(tag) = Tag::parse(["t", first_marker]) { @@ -1709,9 +1453,7 @@ async fn publish_patch_series( } let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags); - let event = builder.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - let event = require_relay_accepted(output, event)?; + let event = pusher.publish_one(builder).await?; if root.is_none() { root = Some(event.clone()); @@ -1722,7 +1464,6 @@ async fn publish_patch_series( root.ok_or_else(|| anyhow::anyhow!("patch series is empty")) } -/// Build a NIP-22 kind-1111 comment. fn comment_builder( root: &Event, parent: Option<&Event>, @@ -1745,7 +1486,7 @@ fn comment_builder( CommentBuilder::new(content, parent_target) .root(root_target) .into_event_builder() - .tags([Tag::coordinate(addr.clone(), None)]) + .tags([Tag::coordinate(addr.clone().into(), None)]) } #[cfg(test)] @@ -1753,6 +1494,7 @@ mod tests { use std::collections::HashSet; use nostr_sdk::prelude::*; + use signed_core::GitEvent; use super::{RepoStore, comment_builder, patch_current_commit}; @@ -1771,7 +1513,7 @@ mod tests { let root = EventBuilder::new(Kind::GitIssue, "issue body") .finalize(&keys) .expect("signed event"); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); + let addr = signed_core::RepoAddr::new(root.pubkey, "my-repo"); let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL"); let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into()) @@ -1785,35 +1527,29 @@ mod tests { assert!(kinds.contains(&expected), "missing {expected} tag"); } - // The uppercase `E` tag scopes the root, with its id, relay hint and author. let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let slice = e.as_slice(); assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[3], root.pubkey.to_hex()); - // The lowercase `e` tag references the parent. - // For a top-level comment the parent is the root itself. let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(e.as_slice()[1], root.id.to_hex()); - // Signed's own `references_root` must keep matching the comment. - assert!(signed_core::references_root(&event, &root.id)); + assert!(event.references_root(&root.id)); } #[test] fn maintainer_filters_name_owner_and_maintainers() { let owner = Keys::generate().public_key(); let maintainer = Keys::generate().public_key(); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo"); + let addr = signed_core::RepoAddr::new(owner, "my-repo"); - // The owner is not among the maintainers, as on a subordinate fork. let filters = RepoStore::maintainer_filters(&addr, &[maintainer]); assert_eq!(filters.len(), 4); let expected = HashSet::from([owner, maintainer]); - // Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag. let named = |filter: &Filter| -> HashSet { let authors = filter.authors.iter().flatten().copied(); let p_tag = filter @@ -1825,7 +1561,6 @@ mod tests { authors.chain(p_tag).collect() }; - // Announcement and state events, scoped to the repository identifier. let announcement = &filters[0]; assert_eq!(named(announcement), expected); assert!( @@ -1834,7 +1569,6 @@ mod tests { .contains_key(&SingleLetterTag::LOWERCASE_D) ); - // Activity filters, scoped to the repository coordinate. for filter in &filters[1..3] { assert_eq!(named(filter), expected); assert!( @@ -1844,7 +1578,6 @@ mod tests { ); } - // Deletions, named by author only. let deletions = &filters[3]; assert_eq!( deletions diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index be79c6d..b3faba6 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; +use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters}; use crate::backend::{Backend, BackendEvent}; use crate::refresh::{RefreshGate, RefreshRequest}; @@ -17,37 +17,27 @@ struct GlobalRepoListStore(Entity); impl Global for GlobalRepoListStore {} -/// NIP-34 activity event counts per repository, ranking the explore list by popularity. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RepoActivityCounts { - /// Root `30611` issue events addressed to the repository. pub issues: u32, - /// Root `3063` pull request events addressed to the repository. - /// - /// PR updates are not new PRs and do not count. + // PR updates are not new PRs and do not count. pub pull_requests: u32, - /// `1617` patch events addressed to the repository. pub commits: u32, } impl RepoActivityCounts { - /// Total issues, pull requests and commits, the popularity ranking key. + // The popularity ranking key. pub fn score(self) -> u32 { self.issues + self.pull_requests + self.commits } } -/// Store listing the discovered repository announcements, newest first. pub struct RepoListStore { - /// Shared so views can clone the list per frame without a deep copy. + // Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, - /// Latest known activity timestamp per repository. pub last_activity: Arc>, - /// Issues, pull requests and commits per repository. - /// - /// Used for the Popular ranking of the explore list. + // For the Popular ranking of the explore list. pub counts: Arc>, - /// Own repositories whose state events were fetched from their announced relays. state_synced_repos: HashSet, refresh: RefreshGate, _subscription: Subscription, @@ -100,7 +90,6 @@ impl RepoListStore { } } - /// The announcements of `user`, newest first. pub fn announcements_of(&self, user: &PublicKey) -> Vec { self.announcements .iter() @@ -115,17 +104,16 @@ impl RepoListStore { backend.update(cx, |backend, cx| { backend.sync_bootstraps( vec![ - filters::all_announcements(), - filters::all_states(), + Filters::all_announcements(), + Filters::all_states(), // Deletion requests, NIP-09/62, must be known before any announcement is shown. - filters::deletions(), + Filters::deletions(), ], cx, ); }); } - /// Fetch the state events of the user's own repositories. fn sync_own_repo_states(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let Some(me) = backend.read(cx).current_user() else { @@ -144,15 +132,13 @@ impl RepoListStore { self.state_synced_repos.insert(addr.clone()); backend.update(cx, |backend, cx| { - backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx); + backend.connect_repo_relays(relays, vec![addr.state_filter()], cx); }); } } - /// Re-query the local database. - /// - /// Runs immediately. The backend pump already batches the relay events that - /// trigger a refresh, so no per-store debounce is needed. + // Runs immediately: the backend pump already batches the relay events + // that trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; @@ -168,14 +154,14 @@ impl RepoListStore { let client = backend.read(cx).client(); let work = cx.background_spawn(async move { - let filter = filters::all_announcements(); + let filter = Filters::all_announcements(); let events = client.database().query(filter).await?; - let deletion_events = client.database().query(filters::deletions()).await?; + let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - // Dedup and sort off the main thread. - // Only the final list crosses back into the entity. + // Dedup and sort off the main thread; only the final list crosses + // back into the entity. let mut by_repo: HashMap = HashMap::new(); for event in events { @@ -213,14 +199,13 @@ impl RepoListStore { let Some(id) = event.tags.identifier() else { continue; }; - let addr = repo_addr(event.pubkey, id); + let addr = RepoAddr::new(event.pubkey, id); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; *entry = (*entry).max(event.created_at); } - // Bound the activity query to a recent window. // Older repos fall back to their announcement or state timestamps. let activity_filter = Filter::new() .kinds(filters::ACTIVITY_KINDS) @@ -230,10 +215,11 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement { + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement { continue; } + let addr = RepoAddr::from(coordinate.clone()); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -241,8 +227,8 @@ impl RepoListStore { } } - // Popularity counts per repository, issues, pull requests and patches. - // Unbounded, unlike the windowed activity query above, so totals are exact. + // Unbounded, unlike the windowed activity query above, so totals + // are exact. let mut counts: HashMap = HashMap::new(); let count_filter = Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); @@ -251,12 +237,13 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement + || !last_activity.contains_key(&RepoAddr::from(coordinate.clone())) { continue; } - let entry = counts.entry(addr).or_default(); + let entry = counts.entry(RepoAddr::from(coordinate)).or_default(); match event.kind { Kind::GitIssue => entry.issues += 1, Kind::GitPullRequest => entry.pull_requests += 1, @@ -272,7 +259,6 @@ impl RepoListStore { cx.spawn(async move |this, cx| { let (announcements, last_activity, counts) = match work.await { Ok(results) => results, - // Database errors are transient, keep the last list. Err(_) => { return this.update(cx, |this, _cx| { this.refresh.abort(); @@ -290,8 +276,6 @@ impl RepoListStore { this.refresh.finish() })?; - // Requests that arrived while the refresh was running. - // They are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index bad4e4d..e3ab674 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -11,7 +11,6 @@ mod title_bar; mod tree_row; pub mod copy_row; -pub mod util; pub use avatar::Avatar; pub use copy_row::{copy_row, menu_copy_row}; @@ -25,4 +24,3 @@ pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; pub use title_bar::title_bar_drag_handlers; pub use tree_row::tree_row; -pub use util::middle_truncate; diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs deleted file mode 100644 index 64d187a..0000000 --- a/crates/signed_ui/src/util.rs +++ /dev/null @@ -1,38 +0,0 @@ -/// `[head chars]...[tail chars]` middle truncation. -/// -/// Values too short for the ellipsis to save space are left alone. -pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { - let len = value.chars().count(); - if len <= head + tail + 3 { - return value.to_string(); - } - let head: String = value.chars().take(head).collect(); - let tail: String = value.chars().skip(len - tail).collect(); - format!("{head}...{tail}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn middle_truncates_long_values_only() { - assert_eq!( - middle_truncate( - "a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d", - 10, - 10, - ), - "a008def157...ad57a3564d" - ); - assert_eq!( - middle_truncate( - "30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit", - 10, - 10 - ), - "30617:a008...3564d:ngit" - ); - assert_eq!(middle_truncate("short", 10, 10), "short"); - } -} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 0fe56c3..14398f5 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,5 +1,9 @@ mod pubkey; +mod repo_url; +mod text; mod time; pub use pubkey::shorten_pubkey; -pub use time::{relative_time, relative_time_secs}; +pub use repo_url::same_repo_url; +pub use text::{flatten_whitespace, middle_truncate}; +pub use time::{latest, relative_time, relative_time_secs, sort_newest_first, sort_oldest_first}; diff --git a/crates/utils/src/pubkey.rs b/crates/utils/src/pubkey.rs index 65b7b01..d8d1ea0 100644 --- a/crates/utils/src/pubkey.rs +++ b/crates/utils/src/pubkey.rs @@ -1,49 +1,14 @@ use nostr::prelude::*; -/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form. +use crate::text::middle_truncate; + +const HEAD_CHARS: usize = 9; +const TAIL_CHARS: usize = 4; + pub fn shorten_pubkey(public_key: PublicKey) -> String { let encoded = public_key .to_bech32() .unwrap_or_else(|_| public_key.to_hex()); - truncate_middle(&encoded) -} - -fn truncate_middle(value: &str) -> String { - const HEAD_CHARS: usize = 9; - const TAIL_CHARS: usize = 4; - - let length = value.chars().count(); - if length <= HEAD_CHARS + TAIL_CHARS + 3 { - return value.to_owned(); - } - - let head: String = value.chars().take(HEAD_CHARS).collect(); - let tail: String = value.chars().skip(length - TAIL_CHARS).collect(); - - format!("{head}...{tail}") -} - -#[cfg(test)] -mod tests { - use super::*; - - const PUBLIC_KEY_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; - - #[test] - fn shortens_a_valid_pubkey() { - let public_key = PublicKey::from_hex(PUBLIC_KEY_HEX).expect("valid pubkey"); - let npub = public_key.to_bech32().expect("valid pubkey encodes"); - - assert_eq!( - shorten_pubkey(public_key), - format!("{}...{}", &npub[..9], &npub[npub.len() - 4..]) - ); - } - - #[test] - fn leaves_short_values_intact() { - assert_eq!(truncate_middle("npub1short"), "npub1short"); - assert_eq!(truncate_middle("thirteenchars"), "thirteenchars"); - } + middle_truncate(&encoded, HEAD_CHARS, TAIL_CHARS) } diff --git a/crates/utils/src/repo_url.rs b/crates/utils/src/repo_url.rs new file mode 100644 index 0000000..cc9bbbd --- /dev/null +++ b/crates/utils/src/repo_url.rs @@ -0,0 +1,20 @@ +use nostr::prelude::*; + +/// Compare git URLs ignoring scheme, trailing `.git` and path slashes, so a +/// grasp announce URL matches its https origin. Unparseable values compare literally. +pub fn same_repo_url(a: &str, b: &str) -> bool { + match (url_identity(a), url_identity(b)) { + (Some(a), Some(b)) => a == b, + _ => a == b, + } +} + +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)) +} diff --git a/crates/utils/src/text.rs b/crates/utils/src/text.rs new file mode 100644 index 0000000..c79c6b5 --- /dev/null +++ b/crates/utils/src/text.rs @@ -0,0 +1,21 @@ +pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { + let len = value.chars().count(); + if len <= head + tail + 3 { + return value.to_string(); + } + let head: String = value.chars().take(head).collect(); + let tail: String = value.chars().skip(len - tail).collect(); + format!("{head}...{tail}") +} + +pub fn flatten_whitespace(text: &str) -> String { + const MAX_CHARS: usize = 200; + let flat = text.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= MAX_CHARS { + flat + } else { + let mut clipped: String = flat.chars().take(MAX_CHARS).collect(); + clipped.push('…'); + clipped + } +} diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index 230ddf9..4070cf0 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -1,6 +1,24 @@ use nostr::prelude::*; -/// Format a timestamp as a short relative time, e.g. `3h ago`. +pub fn sort_newest_first(events: &mut [Event]) { + events.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); +} + +pub fn sort_oldest_first(events: &mut [Event]) { + events.sort_by_key(|e| e.created_at); +} + +pub fn latest(events: I) -> Option +where + I: IntoIterator, +{ + events.into_iter().max_by_key(|e| e.created_at) +} + pub fn relative_time(timestamp: Timestamp) -> String { let now = Timestamp::now().as_secs(); let secs = now.saturating_sub(timestamp.as_secs()); @@ -20,25 +38,6 @@ pub fn relative_time(timestamp: Timestamp) -> String { } } -/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`. pub fn relative_time_secs(secs: i64) -> String { relative_time(Timestamp::from_secs(secs.max(0) as u64)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn formats_relative_time() { - let now = Timestamp::now(); - - assert_eq!(relative_time(now), "just now"); - assert_eq!(relative_time(now - 300), "5m ago"); - assert_eq!(relative_time(now - 7_200), "2h ago"); - assert_eq!(relative_time(now - 3 * 86_400), "3d ago"); - assert_eq!(relative_time(now - 60 * 86_400), "2mo ago"); - assert_eq!(relative_time(now - 800 * 86_400), "2y ago"); - assert_eq!(relative_time(now + 600), "just now"); - } -} diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 17799c8..e91702c 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -17,7 +17,9 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; +use signed_git::{ + CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, +}; use signed_state::RepoStore; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; @@ -355,14 +357,14 @@ impl CommitDiffView { .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) } }) .await; let diff = cx .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit_diff(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) } }) .await; diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 8f04a67..6761735 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -13,7 +13,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; use signed_core::{InboxItem, InboxReadState, RepoAddr}; use signed_state::{ - Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox, + Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, }; use signed_ui::{Avatar, CountBadge}; use utils::relative_time; @@ -199,7 +199,7 @@ impl InboxView { let state = self.state.clone(); - let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await }); + let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await }); self.tasks.push(cx.spawn(async move |this, cx| { let (threads, unread_count) = match work.await { diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index a401252..c5a7131 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -8,7 +8,7 @@ use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use nostr::prelude::EventId; -use signed_core::activity_subject; +use signed_core::GitEvent; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; @@ -103,7 +103,7 @@ impl Render for IssueDetailView { }; ( - activity_subject(issue), + issue.activity_subject(), profile.name(), profile.picture(), store.status_of(issue), diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index b082548..edfba02 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -16,7 +16,7 @@ use gpui_component::{ ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::EventId; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -146,7 +146,7 @@ impl IssuesView { fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context) -> AnyElement { let issue = &self.store.read(cx).issues[issue_ix]; - let title = activity_subject(issue); + let title = issue.activity_subject(); let id_hex = issue.id.to_hex(); let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); let author = profile.name(); diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index d1385c3..58b3b1c 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -21,12 +21,9 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::{Event, EventId, Kind, Url}; -use signed_core::{ - RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, - merge_base_of, pull_request_patch, -}; -use signed_git::{FileCommit, patch_commits, patch_diffs}; -use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; +use signed_core::{GitEvent, PullRequest, RepoAddr}; +use signed_git::{FileCommit, PatchParser}; +use signed_state::{Backend, Mirrors, ProfileStore, RepoStore}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -145,23 +142,24 @@ impl PullRequestDetailView { .iter() .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) .map(|root| { - let update = latest_update(store.pull_requests.iter(), root); + let update = PullRequest::latest_update(store.pull_requests.iter(), root); let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + .and_then(GitEvent::current_commit) + .or_else(|| root.current_commit()); let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + .and_then(GitEvent::merge_base) + .or_else(|| root.merge_base()); - let clone_urls = clone_urls_of(root) + let clone_urls = root + .clone_urls() .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) .unwrap_or_default(); PrBinding { description: root.content.clone(), - patch: pull_request_patch(root, store.patches.iter()), + patch: PullRequest::new(root).patch(store.patches.iter()), tip, base, clone_urls, @@ -235,14 +233,14 @@ impl PullRequestDetailView { let nostr_diff = cx .background_spawn({ let patch = patch.clone(); - async move { patch_diffs(&patch) } + async move { PatchParser::patch_diffs(&patch) } }) .await; let nostr_commits = cx .background_spawn({ let patch = patch.clone(); - async move { patch_commits(&patch) } + async move { PatchParser::patch_commits(&patch) } }) .await; @@ -263,9 +261,10 @@ impl PullRequestDetailView { Some( cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let repo = Mirrors::ensure(&addr, &clone_urls)?; + let gix_repo = repo.inner(); - let workdir = repo + let workdir = gix_repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); @@ -277,17 +276,16 @@ impl PullRequestDetailView { Some(base) => base, // No `merge-base` tag. Use the merge base of the tip and the default branch. None => { - let head = repo + let head = gix_repo .head_id() .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() + let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?; + gix_repo.merge_base(tip_id, head)?.to_string() } }; - let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + let diff = repo.range_diff(&base, &tip)?; + let commits = repo.commit_range(&base, &tip)?; Ok::<_, anyhow::Error>((diff, commits, workdir)) }) @@ -620,9 +618,9 @@ impl PullRequestDetailView { return div().into_any_element(); }; ( - activity_subject(root), + root.activity_subject(), store.status_of(root), - branch_name_of(root), + root.branch_name(), root.pubkey, ) }; diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index b14bf99..beb3a93 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -14,7 +14,7 @@ use gpui_component::{ ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::{EventId, Kind}; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -159,7 +159,7 @@ impl PullRequestsView { fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr_id = pr.id; - let title = activity_subject(pr); + let title = pr.activity_subject(); let id_hex = pr.id.to_hex(); let age = relative_time(pr.created_at); diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 4d29ba1..d3cb0df 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -21,15 +21,11 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::*; -use signed_core::{Announcement, RepoAddr, fork_candidates}; -use signed_git::{ - delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, - worktree_commit_range_commits, worktree_commit_range_diff, -}; -use signed_state::{ - Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path, -}; +use signed_core::{Announcement, RepoAddr}; +use signed_git::{GitCache, Repo}; +use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; +use utils::middle_truncate; use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row}; use crate::views::{repo_tab_avatar, tab_title}; @@ -104,17 +100,8 @@ fn shorten_owner(owner: &PublicKey) -> String { 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) + SharedString::from(middle_truncate(label, 18, 0)) } fn checkout_source_item( @@ -407,10 +394,12 @@ impl NewPullRequestView { .background_spawn({ let path = path.clone(); async move { - let repo = gix::open(Path::new(&path)).ok()?; - let branches = - signed_git::worktree_branches(Path::new(&path)).unwrap_or_default(); - let current = signed_git::current_branch(&repo).ok().flatten(); + let repo = Repo::try_open(Path::new(&path)); + let branches = repo + .as_ref() + .and_then(|repo| repo.branches().ok()) + .unwrap_or_default(); + let current = repo.as_ref().and_then(|repo| repo.current_branch()); Some((branches, current)) } }) @@ -520,7 +509,7 @@ impl NewPullRequestView { }; 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) + Announcement::forks_in(&announcements, &base, euc.as_deref(), user) .into_iter() .cloned() .collect() @@ -540,8 +529,8 @@ impl NewPullRequestView { let Some((base, _euc)) = self.base_repo(cx) else { return; }; - let mirror_path = repo_mirror_path(&base); - let namespace = fork_namespace(&announcement); + let mirror_path = Mirrors::path(&base); + let namespace = GitCache::fork_namespace(&announcement); let clone_urls = announcement.clone.clone(); let base_clone_urls: Vec = self @@ -575,14 +564,14 @@ impl NewPullRequestView { let clone_urls = clone_urls.clone(); let mirror_path = mirror_path.clone(); async move { - ensure_repo_mirror(&base, &base_clone_urls)?; + Mirrors::ensure(&base, &base_clone_urls)?; + let repo = Repo::open(&mirror_path)?; // Prune stale imports of any fork. Then import this fork's heads under its namespace. - delete_refs_with_prefix(&mirror_path, "refs/fork")?; + repo.delete_refs_with_prefix("refs/fork")?; // Fetch the fork's refs and import them under the fork's namespace. - fetch_repo_refs( - &mirror_path, + repo.fetch_refs( &clone_urls, &format!("+refs/heads/*:refs/fork/{namespace}/*"), )?; @@ -602,12 +591,12 @@ impl NewPullRequestView { }; let base_branches = strip( - refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + repo.refs_with_prefix("refs/remotes/origin")?, "refs/remotes/origin", ); let compare_branches = strip( - refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, &format!("refs/fork/{namespace}"), ); @@ -781,24 +770,17 @@ impl NewPullRequestView { 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(|| { + let repo = Repo::open(Path::new(&repo_path))?; + let merge_base = + repo.merge_base(&base, &compare)?.ok_or_else(|| { anyhow::anyhow!( "{base_name} and {compare_name} share no common ancestor" ) })?; - let commits = worktree_commit_range_commits( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let commits = repo.commit_range(&merge_base, &compare)?; - let diff = worktree_commit_range_diff( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let diff = repo.range_diff(&merge_base, &compare)?; Ok::<_, anyhow::Error>((merge_base, commits, diff)) } diff --git a/crates/workspace/src/views/repo/about.rs b/crates/workspace/src/views/repo/about.rs index bfd6fbc..145bcdf 100644 --- a/crates/workspace/src/views/repo/about.rs +++ b/crates/workspace/src/views/repo/about.rs @@ -5,7 +5,8 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use nostr::prelude::PublicKey; use signed_core::Announcement; use signed_state::ProfileStore; -use signed_ui::{Avatar, middle_truncate}; +use signed_ui::Avatar; +use utils::middle_truncate; pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index ebd3fe9..c9f1bba 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -11,7 +11,7 @@ use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; -use signed_git::{FileCommit, WorktreeSnapshot}; +use signed_git::{FileCommit, Repo, WorktreeSnapshot}; use signed_ui::{placeholder, tree_row}; use crate::views::tree::{TreeItemSeed, tree_items}; @@ -611,9 +611,9 @@ impl RepoFilesView { let task: Task> = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels)) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 21360ed..52dda2f 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -8,7 +8,7 @@ use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list}; -use signed_git::CommitList; +use signed_git::{CommitList, Repo}; use signed_state::RepoStore; use signed_ui::placeholder; @@ -69,7 +69,9 @@ impl RepoHistoryView { let task: Task> = cx.spawn(async move |this, cx| { let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.all_commits()) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 8161042..a16c9f4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -5,7 +5,6 @@ use std::rc::Rc; use anyhow::Error; use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; -use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, @@ -24,16 +23,15 @@ use gpui_component::{ use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; -use signed_git::FileCommit; +use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ - Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, - ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, - pr_proposes_checkout, + Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind, + ProfileStore, RepoListStore, RepoStore, }; use signed_ui::{ - Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate, - ref_selector_trigger, + Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, }; +use utils::middle_truncate; mod about; mod actions; @@ -326,7 +324,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { - let repo = gix::open(&local_path)?; + let repo = Repo::open(&local_path)?; load_repo_data(&repo) }) .await; @@ -360,7 +358,7 @@ impl RepoDetailView { let disk = { let addr = addr.clone(); cx.background_spawn(async move { - match open_repo_mirror(&addr)? { + match Mirrors::open(&addr)? { Some(repo) => Ok(Some(load_repo_data(&repo)?)), None => Ok(None), } @@ -377,7 +375,7 @@ impl RepoDetailView { let addr = addr.clone(); let clone_urls = clone_urls.clone(); cx.background_spawn(async move { - let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let repo = Mirrors::ensure(&addr, &clone_urls)?; load_repo_data(&repo) }) .await @@ -404,42 +402,27 @@ impl RepoDetailView { let addr = addr.clone(); cx.background_spawn(async move { - let Some(repo) = open_repo_mirror(&addr)? else { + let Some(repo) = Mirrors::open(&addr)? else { return Ok::<_, Error>(None); }; // Best-effort, a fetch failure, e.g. offline, keeps the cached state. - signed_git::fetch_all(&repo).ok(); + repo.fetch().ok(); - let worktree = repo.workdir().map(Path::to_path_buf); + let moved = repo.fast_forward_branches().unwrap_or(false); - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - None => (Vec::new(), Vec::new()), - }; - - let current_branch = signed_git::current_branch(&repo).unwrap_or(None); - let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - - Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) + Ok::<_, Error>(Some((moved, repo.snapshot()?))) }) } .await; this.update_in(cx, |this, window, cx| { - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); + if let Ok(Some((moved, snapshot))) = refresh { + let branches: Vec = + snapshot.branches.iter().map(Into::into).collect(); + let tags: Vec = snapshot.tags.iter().map(Into::into).collect(); + let current_branch = snapshot.current_branch.clone(); + let head_commit = snapshot.head_commit.clone(); let branches_changed = this.refs.set_branches( branches, @@ -531,8 +514,8 @@ impl RepoDetailView { .as_ref() .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); - let name = signed_git::sanitize_path_component(&name); + .unwrap_or_else(|| addr.identifier().to_owned()); + let name = GitCache::sanitize_path_component(&name); if name.is_empty() { "repository".to_owned() @@ -617,13 +600,12 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { + let repo = Repo::open(&worktree); match kind { RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) + repo.and_then(|repo| repo.checkout_branch(&checkout_name)) } + RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)), } }) .await; @@ -665,7 +647,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; // Build the tree off the main thread, like [`Self::load_repo`]. let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); @@ -716,7 +698,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); Ok::<_, Error>((snapshot, tree, paths)) @@ -1523,8 +1505,12 @@ impl RepoDetailView { continue; } for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { + if CheckoutsStore::pr_proposes_checkout( + pr, + store.status_of(pr) == RepoStatus::Open, + user, + &status, + ) { continue 'status; } } @@ -1857,7 +1843,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); (SharedString::from(format!("Forked from {name}")), true) } None => (SharedString::from(upstream.display().as_str()), false), @@ -1900,7 +1886,7 @@ impl ShareTargets { fn from_announcement(announcement: &Announcement) -> Self { let addr = announcement.addr(); let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) .to_bech32() .expect("a complete coordinate always encodes to naddr"); @@ -1952,38 +1938,21 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { format!("{}...{}", &url[..end], &url[url.len() - tail..]) } -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - +fn load_repo_data(repo: &Repo) -> Result { + let snapshot = repo.snapshot()?; let worktree = repo.workdir().map(Path::to_path_buf); - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; + let tree = build_tree_items(&snapshot.entries); Ok(RepoData { tree, - entries, - readme_path, - readme, + entries: snapshot.entries, + readme_path: snapshot.readme_path, + readme: snapshot.readme, worktree, - branches, - tags, - current_branch, - head_commit, + branches: snapshot.branches, + tags: snapshot.tags, + current_branch: snapshot.current_branch, + head_commit: snapshot.head_commit, }) } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 2fde423..16fc379 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -228,7 +228,7 @@ impl RepoListView { .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); Some(SharedString::from(format!("forked from {name}"))) }); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index fd16d89..396f441 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,7 +21,7 @@ use nostr::prelude::RelayUrl; use signed_core::{Announcement, RepoAddr}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, - ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos, + ProfileStore, RepoListStore, ResolvedLocalRepo, }; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; @@ -136,7 +136,7 @@ impl SidebarPanel { let local = LocalReposStore::global(cx).read(cx); let local_repos = - resolve_local_repos(&local.repos, &repo_list.announcements, &announcements); + LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements); (announcements, local_repos, local.scanning) }; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 825b873..f0485dd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,7 +1,8 @@ use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; use gpui::{Context, Entity, Render, Subscription, Window, div, px}; -use gpui_component::{Root, StyledExt, Theme}; +use gpui_component::notification::NotificationType; +use gpui_component::{Root, StyledExt, Theme, WindowExt}; use settings::{AppearanceMode, SettingsStore}; use signed_state::{Backend, BackendEvent}; @@ -66,6 +67,10 @@ impl Workspace { if matches!(event, BackendEvent::PassphraseRequired) { passphrase_dialog::open(window, cx); } + + if let BackendEvent::Error(message) = event { + window.push_notification((NotificationType::Error, message.clone()), cx); + } }, )); diff --git a/docs/TODO.md b/docs/TODO.md index 6393d0b..4640904 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,18 +1 @@ # TODO - -## `BackendEvent::SyncProgress` - -File: `crates/signed_state/src/backend.rs` - -Kept intentionally. The progress pipeline (the `SyncProgress` variant, the `sync_progress` -field and its accessor, and the progress task in `sync_bootstrap`) is retained for a planned -sync progress indicator. No subscriber exists yet. Do not remove it without revisiting that -plan. - -## `login` / `logout` family - -File: `crates/signed_state/src/backend.rs` - -No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to -delete the family (`login`, `login_with_new_identity`, `login_with_nsec`, -`login_with_bunker`, `logout`) or wire the stub to `Backend::login`.