This commit is contained in:
2026-09-10 10:00:32 +07:00
parent 0e9f0a33ac
commit 68dbc5a731
13 changed files with 1065 additions and 720 deletions
+4 -1
View File
@@ -11,6 +11,9 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url};
pub use deletions::Deletions;
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
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};
+294 -1
View File
@@ -180,7 +180,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
}
/// The `c` tag of an event, the tip of the proposed branch, as hex.
fn current_commit_of(event: &Event) -> Option<String> {
pub fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
.iter()
@@ -190,6 +190,82 @@ fn current_commit_of(event: &Event) -> Option<String> {
})
}
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
pub fn merge_base_of(event: &Event) -> Option<String> {
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<Vec<Url>> {
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<String> {
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<Item = &'a Event>,
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)
}
/// 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<PublicKey>,
) -> 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()
}
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
///
/// It lets clients find existing patches for a specific commit.
@@ -727,4 +803,221 @@ mod tests {
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
/// Build a signed event of `kind` with the given tags and `created_at`.
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn reads_current_commit_and_branch_name() {
let pr = pr_root();
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
}
#[test]
fn returns_none_without_pr_tags() {
let pr = signed_at(Kind::GitPullRequest, vec![], 100);
assert_eq!(current_commit_of(&pr), None);
assert_eq!(branch_name_of(&pr), None);
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
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")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.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());
}
#[test]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root).is_none());
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Build a signed kind-30617 event for `owner` with the given tags.
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = crate::repo_addr(
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,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
// The user's fork comes first, then the other author's.
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = crate::repo_addr(base_owner, "upstream");
let mut all = vec![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
// Without a base EUC only `u`-tag forks match.
all.push(
owned_announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["u-fork"]);
}
}
+25 -1
View File
@@ -7,7 +7,7 @@ use anyhow::{Context, Result, bail};
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
use gix::interrupt::IS_INTERRUPTED;
use gix::progress::Discard;
use signed_core::RepoAddr;
use signed_core::{Announcement, RepoAddr};
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
#[derive(Debug, Clone)]
@@ -1061,6 +1061,15 @@ pub fn sanitize_path_component(id: &str) -> String {
sanitized
}
/// 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)
)
}
/// 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.
@@ -2378,6 +2387,21 @@ mod tests {
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
}
#[test]
fn fork_namespace_combines_owner_and_sanitized_id() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([Tag::parse(["d", "my/repo"]).expect("valid tag")])
.finalize(&keys)
.expect("signed event");
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(
fork_namespace(&announcement),
format!("{}/my_repo", keys.public_key().to_hex())
);
}
#[test]
fn repo_path_stays_inside_root() {
let cache = GitCache::new("/cache".into());
+202 -175
View File
@@ -1,9 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -33,6 +31,13 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// 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"];
/// Delay the notification pump waits for more events before emitting a batch.
///
/// A negentropy sync can deliver hundreds of events in a burst; batching
/// them here means every subscriber debounces the burst once, not once per
/// subscriber.
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
@@ -41,8 +46,13 @@ pub enum BackendEvent {
PassphraseRequired,
/// The signer changed on login, logout or account switch.
SignerChanged,
/// A new event was received from a relay and stored in the database.
NostrUpdate(Update),
/// New events were received from a relay and stored in the database.
///
/// Batched: [`Backend`]'s notification pump coalesces everything a
/// relay delivers within one debounce window into a single event,
/// instead of emitting per-event and making every subscriber debounce
/// the same burst independently.
NostrUpdate(Vec<Update>),
/// A negentropy sync completed.
Synced,
/// A negentropy sync is in flight.
@@ -78,29 +88,17 @@ pub struct Backend {
/// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool,
/// Repositories with a push in flight, mirror or checkout based.
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
///
/// A child entity: views that only care whether one repository is
/// pushing can `cx.observe` it without being invoked on unrelated
/// `Backend` changes (a `sync_progress` tick, a new relay connecting).
pushing_repos: Entity<HashSet<RepoAddr>>,
}
struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {}
/// Removes its repository from the in-flight push set when dropped.
///
/// A push task cancelled by its panel closing cannot leave the repository locked.
struct PushGuard {
repos: Arc<Mutex<HashSet<RepoAddr>>>,
addr: RepoAddr,
}
impl Drop for PushGuard {
fn drop(&mut self) {
if let Ok(mut repos) = self.repos.lock() {
repos.remove(&self.addr);
}
}
}
impl EventEmitter<BackendEvent> for Backend {}
impl Backend {
@@ -118,16 +116,45 @@ impl Backend {
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let mut notifications = pump_client.notifications();
let mut pending: Vec<Update> = Vec::new();
while let Some(notification) = notifications.next().await {
let ClientNotification::Event { event, .. } = notification else {
continue;
};
'outer: loop {
// Wait for the first event of a batch.
match notifications.next().await {
Some(ClientNotification::Event { event, .. }) => {
pending.push(Update::from_event(&event));
}
Some(_) => continue,
None => break,
}
let update = Update::from_event(&event);
// Collect everything else that arrives within the debounce window.
let deadline = Instant::now() + PUMP_DEBOUNCE;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
let timer = cx.background_executor().timer(deadline - now);
futures::pin_mut!(timer);
let next = notifications.next();
futures::pin_mut!(next);
match futures::future::select(next, timer).await {
futures::future::Either::Left((
Some(ClientNotification::Event { event, .. }),
_,
)) => {
pending.push(Update::from_event(&event));
}
futures::future::Either::Left((Some(_), _)) => continue,
futures::future::Either::Left((None, _)) => break 'outer,
futures::future::Either::Right(_) => break,
}
}
let batch = std::mem::take(&mut pending);
if this
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
.is_err()
{
break;
@@ -139,16 +166,21 @@ impl Backend {
pump.detach();
let mut this = Self {
let this = Self {
client,
signer,
current_user: None,
sync_progress: None,
passphrase_required: false,
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
pushing_repos: cx.new(|_| HashSet::new()),
};
this.bootstrap(cx);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
log::warn!("backend dropped before bootstrap could run: {error}");
}
});
this
}
@@ -351,24 +383,31 @@ impl Backend {
]
.to_vec();
this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx);
let metadata = Metadata::new()
.name(&name)
.display_name(&name)
.into_event_builder();
this.send_fire_and_forget(metadata, cx);
let grasp_servers: Vec<RelayUrl> = ["wss://gitnostr.com", "wss://relay.ngit.dev"]
.into_iter()
.map(|url| RelayUrl::parse(url).expect("valid relay URL"))
.collect();
this.send_fire_and_forget(
let client = this.client.clone();
let signer = this.signer.clone();
for builder in [
RelayList::new(relays).into_event_builder(),
metadata,
GitUserGraspList { grasp_servers }.into_event_builder(),
cx,
);
] {
let client = client.clone();
let signer = signer.clone();
cx.spawn(async move |_this, _cx| {
publish_best_effort(&client, &signer, builder).await
})
.detach();
}
})?;
Ok(public_key)
@@ -486,18 +525,21 @@ impl Backend {
maintainers: Vec::new(),
};
let event = this
.update(cx, |this, cx| {
this.send(announcement.into_event_builder(), cx)
})?
.await?;
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?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
event
};
// 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 (client, signer) =
this.update(cx, |this, _cx| (this.client.clone(), this.signer.clone()))?;
let refs = vec![("refs/heads/main".to_owned(), commit)];
let push = cx.background_spawn({
@@ -545,9 +587,11 @@ impl Backend {
// Staging already stored the event locally, publishing makes it
// visible to the other relays and clients.
if let Some(state_event) = &outcome.state_event {
broadcast_event(&client, state_event).await.ok();
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(state_event.clone())));
if let Err(e) = client.send_event(state_event).broadcast().await {
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| {
this.announce_published(state_event.clone(), cx)
})
.ok();
}
@@ -633,11 +677,16 @@ impl Backend {
maintainers: Vec::new(),
};
let event = this
.update(cx, |this, cx| {
this.send(announcement.into_event_builder(), cx)
})?
.await?;
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?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
event
};
let refs = state.refs.clone();
let head = state.head.clone();
@@ -647,9 +696,6 @@ impl Backend {
// 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.
let (client, signer) =
this.update(cx, |this, _cx| (this.client.clone(), this.signer.clone()))?;
if !refs.is_empty() {
let push = cx.background_spawn({
let client = client.clone();
@@ -695,11 +741,11 @@ impl Backend {
// 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 {
broadcast_event(&client, state_event).await.ok();
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(state_event.clone())));
})
.ok();
if let Err(e) = client.send_event(state_event).broadcast().await {
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
.ok();
}
}
@@ -754,31 +800,34 @@ impl Backend {
cx: &mut Context<Self>,
) -> Task<Result<PushOutcome, Error>> {
let addr = announcement.addr();
let guard = {
let mut pushing = self
.pushing_repos
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !pushing.insert(addr.clone()) {
return Task::ready(Err(anyhow!(
"A push to this repository is already in progress"
)));
}
if self.pushing_repos.read(cx).contains(&addr) {
return Task::ready(Err(anyhow!(
"A push to this repository is already in progress"
)));
}
PushGuard {
repos: self.pushing_repos.clone(),
addr: addr.clone(),
}
};
self.pushing_repos.update(cx, |pushing, cx| {
pushing.insert(addr.clone());
cx.notify();
});
let owner = announcement.owner.to_bech32().unwrap();
let repo_id = announcement.id.clone();
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// Held for the whole task. Dropped on completion, on error and on cancellation alike.
let _guard = guard;
// Held for the whole task. Runs on completion, on error and on
// cancellation alike, since dropping the task drops this guard.
let _guard = cx.on_drop(&this, {
let addr = addr.clone();
move |backend, cx| {
backend.pushing_repos.update(cx, |pushing, cx| {
pushing.remove(&addr);
cx.notify();
});
}
});
let mut state = {
let work = cx.background_spawn({
@@ -854,9 +903,11 @@ impl Backend {
// Staging already stored the event locally, publishing notifies
// the repository views and other relays and clients.
if let Some(state_event) = &outcome.state_event {
broadcast_event(&client, state_event).await.ok();
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(state_event.clone())));
if let Err(e) = client.send_event(state_event).broadcast().await {
log::warn!("failed to broadcast repository state: {e}");
}
this.update(cx, |this, cx| {
this.announce_published(state_event.clone(), cx)
})
.ok();
}
@@ -1063,6 +1114,13 @@ impl Backend {
self.signer.clone()
}
/// Repositories with a push in flight, mirror or checkout based.
///
/// A child entity: `cx.observe` it to react only to push-state changes.
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
self.pushing_repos.clone()
}
/// Get the current user's public key.
pub fn current_user(&self) -> Option<PublicKey> {
self.current_user
@@ -1221,108 +1279,59 @@ impl Backend {
task.detach();
}
/// Sign, broadcast and locally store an event.
pub fn send(
&mut self,
builder: EventBuilder,
cx: &mut Context<Self>,
) -> Task<Result<Event, Error>> {
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
///
/// Callers publish with `client.send_event(...)` directly, then call this
/// so stores like `RepoListStore` refresh without re-querying the relays.
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
cx.emit(BackendEvent::Published(Box::new(event)));
}
/// 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.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
let client = self.client.clone();
let signer = self.signer.clone();
self.publish_task(cx, async move {
// Sign with the current signer, broadcast and save locally.
// The event is immediately visible to database queries.
let event = builder.finalize_async(&signer).await?;
broadcast_event(&client, &event).await
})
}
for event in events.iter().cloned() {
let client = client.clone();
let signer = signer.clone();
/// Broadcast and locally store an already-signed event.
pub fn publish_event(
&mut self,
event: Event,
cx: &mut Context<Self>,
) -> Task<Result<Event, Error>> {
let client = self.client.clone();
self.publish_task(cx, async move { broadcast_event(&client, &event).await })
}
/// Run `work` in the background, then emit its outcome as a [`BackendEvent`].
fn publish_task(
&mut self,
cx: &mut Context<Self>,
work: impl Future<Output = Result<Event, Error>> + 'static + Send,
) -> Task<Result<Event, Error>> {
cx.spawn(async move |this, cx| {
let result = cx.background_spawn(work).await;
match &result {
Ok(event) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone())));
})
.ok();
cx.spawn(async move |_this, _cx| {
if let Err(e) = retract_event(&client, &signer, &event).await {
log::warn!("failed to retract event {}: {e}", event.id);
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})
.ok();
}
}
result
})
}
/// Sign, broadcast and store an event without awaiting the result.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let publish = self.send(builder, cx);
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
if let Err(e) = publish.await {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})
.ok();
}
Ok(())
});
task.detach();
}
/// Publish NIP-09 deletions for `events`, best-effort.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
if events.is_empty() {
return;
})
.detach();
}
let mut tags: Vec<Tag> = Vec::with_capacity(events.len() * 2);
for event in events {
tags.push(Tag::event(event.id));
tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag"));
}
let publish = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
if let Err(e) = publish.await {
log::warn!("failed to retract repository events: {e}");
}
Ok(())
});
task.detach();
}
}
/// Broadcast an event and fail when no relay accepted it.
///
/// The client stores accepted events locally, visible to database queries.
async fn broadcast_event(client: &Client, event: &Event) -> Result<Event, Error> {
let output = client.send_event(event).await?;
/// Sign and send a single NIP-09 deletion request for `event`.
async fn retract_event(
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.
///
/// The SDK does not treat "accepted by zero relays" as an error on its own:
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
/// This turns that case into an error the caller can surface.
pub(crate) fn require_relay_accepted(
output: SendEventOutput,
event: Event,
) -> Result<Event, Error> {
if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output
.failed
@@ -1330,10 +1339,28 @@ async fn broadcast_event(client: &Client, event: &Event) -> Result<Event, Error>
.cloned()
.collect::<Vec<String>>()
.join(", ");
return Err(anyhow!("event not accepted by any relay: {reasons}"));
bail!("event not accepted by any relay: {reasons}");
}
Ok(event.clone())
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}");
}
}
/// Add the given relays, connect and fetch the filters.
+8 -4
View File
@@ -10,9 +10,8 @@ use signed_core::{Announcement, RepoAddr};
use crate::backend::{Backend, BackendEvent};
use crate::git_store::GitStore;
use crate::local_repos::LocalReposStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repo_list::RepoListStore;
use crate::repos::{LocalReposStore, RepoListStore};
/// Delay between a refresh request and the actual re-computation.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -154,7 +153,7 @@ impl CheckoutsStore {
}));
}
let mut store = Self {
let store = Self {
by_repo: HashMap::new(),
statuses: HashMap::new(),
status_requested: HashSet::new(),
@@ -168,7 +167,12 @@ impl CheckoutsStore {
};
if !cfg!(target_arch = "wasm32") {
store.refresh(cx);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
log::warn!("checkouts store dropped before initial refresh could run: {error}");
}
});
}
store
+2 -4
View File
@@ -1,11 +1,10 @@
mod backend;
mod checkouts;
mod git_store;
mod local_repos;
mod profile;
mod refresh;
mod repo;
mod repo_list;
mod repos;
use std::path::{Path, PathBuf};
@@ -13,11 +12,10 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore;
use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore;
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore;
pub use repo_list::{RepoActivityCounts, RepoListStore};
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
/// Initialize the backend and stores, and install them as globals.
-103
View File
@@ -1,103 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global};
use signed_git::find_git_repos;
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let mut store = Self {
roots: Arc::new(roots),
repos: Arc::new(Vec::new()),
scanning: false,
scan_dirty: false,
};
store.rescan(cx);
store
}
/// Forget a repository that has just been published to NIP-34.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new(
self.repos
.iter()
.filter(|repo| repo.as_path() != path)
.cloned()
.collect(),
);
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
return;
}
if self.roots.is_empty() {
return;
}
self.scanning = true;
cx.notify();
let roots = self.roots.clone();
let work = cx.background_spawn(async move {
let mut repos = Vec::new();
for root in roots.iter() {
repos.extend(find_git_repos(root));
}
repos.sort();
repos.dedup();
repos
});
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let repos = work.await;
let again = this.update(cx, |this, cx| {
this.repos = Arc::new(repos);
this.scanning = false;
cx.notify();
let dirty = this.scan_dirty;
this.scan_dirty = false;
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
Ok(())
});
task.detach();
}
}
+15 -5
View File
@@ -96,8 +96,13 @@ impl ProfileStore {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
this.apply_author(update.author, cx);
BackendEvent::NostrUpdate(updates) => {
for update in updates
.iter()
.filter(|update| update.kind == Kind::Metadata)
{
this.apply_author(update.author, cx);
}
}
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
@@ -118,14 +123,19 @@ impl ProfileStore {
})
.detach();
let mut store = Self {
let store = Self {
profiles: HashMap::new(),
seen: RefCell::new(HashSet::new()),
sender,
_subscription: subscription,
};
store.load(cx);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
log::warn!("profile store dropped before initial load could run: {error}");
}
});
store
}
@@ -329,7 +339,7 @@ impl ProfileStore {
// Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => {
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
}
Err(e) => log::warn!("profile sync failed: {e}"),
}
+166 -43
View File
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Error;
use anyhow::{Error, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder;
@@ -14,12 +14,13 @@ use signed_core::{
};
use crate::backend::{
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
user_grasp_list_servers,
};
use crate::checkouts::CheckoutsStore;
use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repo_list::RepoListStore;
use crate::repos::RepoListStore;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -90,7 +91,7 @@ impl RepoStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
@@ -106,7 +107,7 @@ impl RepoStore {
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
@@ -126,7 +127,7 @@ impl RepoStore {
}
});
let mut store = Self {
let store = Self {
addr,
announcement: None,
head: None,
@@ -149,12 +150,20 @@ impl RepoStore {
_subscription: subscription,
};
store.subscribe_remote(cx);
// The announcement we opened the repo from may already list its relays.
// Connect to them right away.
// Do not wait for the bootstrap fetch to return the same event.
store.connect_announced_relays(&announced_relays, cx);
store.refresh(cx);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
// The announcement we opened the repo from may already list its relays.
// Connect to them right away.
// Do not wait for the bootstrap fetch to return the same event.
this.connect_announced_relays(&announced_relays, cx);
this.refresh(cx);
});
if let Err(error) = result {
log::warn!("repo store dropped before bootstrap could run: {error}");
}
});
store
}
@@ -509,7 +518,7 @@ impl RepoStore {
}
.into_event_builder();
self.send(builder, cx);
self.publish(builder, cx);
}
/// Comments on a root event, an issue or PR, oldest first.
@@ -540,7 +549,7 @@ impl RepoStore {
.and_then(|a| a.relays.first())
.cloned();
self.send(
self.publish(
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
cx,
);
@@ -793,12 +802,15 @@ impl RepoStore {
}
}
let publish_task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.publish_event(event, cx))
})?;
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
let pr_event = match publish_task.await {
let publish_result: Result<Event, Error> = async {
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
let pr_event = match publish_result {
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
@@ -808,6 +820,11 @@ impl RepoStore {
}
};
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
})?;
// 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 {
@@ -821,6 +838,59 @@ impl RepoStore {
.detach();
}
/// 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,
repo_path: PathBuf,
merge_base: String,
compare_ref: String,
subject: Option<String>,
description: String,
branch_name: Option<String>,
draft: bool,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
cx.spawn(async move |this, cx| {
// Regenerate the series at submit time.
// 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)
}
})
.await;
let patch = match patch {
Ok(patch) if !patch.is_empty() => patch,
Ok(_) => bail!("No commits between the branches to propose"),
Err(error) => bail!("Failed to generate the patch: {error}"),
};
this.update(cx, |this, cx| {
this.open_pull_request(
subject,
description,
branch_name,
patch,
draft,
Some(merge_base),
Some(repo_path),
cx,
);
})
})
}
/// Update a pull request.
///
/// Other authors must open a new PR.
@@ -909,7 +979,7 @@ impl RepoStore {
});
}
let update_task = this.update(cx, |this, cx| {
let builder = this.update(cx, |this, _cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
pull_request_event: root.id,
@@ -922,20 +992,39 @@ impl RepoStore {
// The `r` EUC tag lets clients subscribe to all PR updates.
// The SDK builder omits it.
let builder = match euc.as_deref() {
match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder,
};
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
}
})?;
if let Err(e) = update_task.await {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
let (client, signer) = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
let backend = backend.read(cx);
(backend.client(), backend.signer())
})?;
let publish_result: Result<Event, Error> = async {
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
}
Err(e) => {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
}
Ok(())
@@ -979,7 +1068,7 @@ impl RepoStore {
Tag::coordinate(self.addr.clone(), None),
]);
self.send(builder, cx);
self.publish(builder, cx);
}
/// Merge a pull request.
@@ -1326,22 +1415,47 @@ impl RepoStore {
}
}
self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
}
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
/// 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.
fn publish(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None;
let backend = Backend::global(cx);
let publish = backend.update(cx, |backend, cx| backend.send(builder, cx));
let (client, signer) = {
let backend = backend.read(cx);
(backend.client(), backend.signer())
};
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
if let Err(e) = publish.await {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
let publish_result: Result<Event, Error> = async {
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(event, cx))
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
}
Ok(())
});
task.detach();
@@ -1425,6 +1539,12 @@ async fn publish_patch_series(
first_marker: &str,
reply_to: Option<EventId>,
) -> Result<Event, Error> {
let (client, signer) = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
let backend = backend.read(cx);
(backend.client(), backend.signer())
})?;
let mut root: Option<Event> = None;
let mut previous = reply_to;
@@ -1465,11 +1585,14 @@ async fn publish_patch_series(
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
let task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
let event = task.await?;
if root.is_none() {
root = Some(event.clone());
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@@ -6,10 +7,114 @@ 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_git::find_git_repos;
use crate::backend::{Backend, BackendEvent};
use crate::refresh::{RefreshGate, RefreshRequest};
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let store = Self {
roots: Arc::new(roots),
repos: Arc::new(Vec::new()),
scanning: false,
scan_dirty: false,
};
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
log::warn!("local repos store dropped before initial scan could run: {error}");
}
});
store
}
/// Forget a repository that has just been published to NIP-34.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new(
self.repos
.iter()
.filter(|repo| repo.as_path() != path)
.cloned()
.collect(),
);
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
return;
}
if self.roots.is_empty() {
return;
}
self.scanning = true;
cx.notify();
let roots = self.roots.clone();
let work = cx.background_spawn(async move {
let mut repos = Vec::new();
for root in roots.iter() {
repos.extend(find_git_repos(root));
}
repos.sort();
repos.dedup();
repos
});
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let repos = work.await;
let again = this.update(cx, |this, cx| {
this.repos = Arc::new(repos);
this.scanning = false;
cx.notify();
let dirty = this.scan_dirty;
this.scan_dirty = false;
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
Ok(())
});
task.detach();
}
}
/// Delay between a refresh request and the actual re-query.
///
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
@@ -74,7 +179,7 @@ impl RepoListStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target anything we list, always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true
@@ -87,7 +192,7 @@ impl RepoListStore {
let is_repo_state = update.kind == Kind::RepoState;
is_announcement || is_repo_state
}
}
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
@@ -107,7 +212,7 @@ impl RepoListStore {
}
});
let mut store = Self {
let store = Self {
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
counts: Arc::new(HashMap::new()),
@@ -115,10 +220,18 @@ impl RepoListStore {
_subscription: subscription,
};
store.subscribe_remote(cx);
// Query the local database right away.
// The list never waits for the relay syncs started above to finish.
store.refresh_initial(cx);
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
// Query the local database right away.
// The list never waits for the relay syncs started above to finish.
this.refresh_initial(cx);
});
if let Err(error) = result {
log::warn!("repo list store dropped before bootstrap could run: {error}");
}
});
store
}
@@ -21,10 +21,10 @@ use gpui_component::{
v_virtual_list,
};
use nostr::prelude::*;
use signed_core::{Announcement, RepoAddr};
use signed_core::{Announcement, RepoAddr, fork_candidates};
use signed_git::{
delete_refs_with_prefix, fetch_repo_refs, format_patch_between, merge_base, refs_with_prefix,
sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff,
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, GitStore, RepoListStore, RepoStore};
use signed_ui::{CountBadge, placeholder};
@@ -102,36 +102,6 @@ impl ForkCompare {
}
}
/// The refs namespace of a fork's import in the target mirror.
fn fork_namespace(announcement: &Announcement) -> String {
format!(
"{}/{}",
announcement.owner.to_hex(),
sanitize_path_component(&announcement.id)
)
}
/// The announced forks of `base` a New PR compare can be built from.
fn fork_candidates<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
base_euc: Option<&str>,
user: Option<PublicKey>,
) -> Vec<&'a Announcement> {
let (mut own, mut others) = (Vec::new(), Vec::new());
for announcement in announcements {
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
continue;
}
if Some(announcement.owner) == user {
own.push(announcement);
} else {
others.push(announcement);
}
}
own.into_iter().chain(others).collect()
}
/// The display name of an announcement.
///
/// Its human-readable name, falling back to the repository id.
@@ -933,54 +903,31 @@ impl NewPullRequestView {
cx.spawn_in(window, async move |this, cx| {
// Regenerate the series at submit time.
// 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 {
format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref)
}
})
.await;
let publish = store.update(cx, |store, cx| {
store.open_pull_request_from_refs(
repo_path,
merge_base,
compare_ref,
(!subject.is_empty()).then_some(subject),
description,
Some(branch_name),
false,
cx,
)
});
let patch = match patch {
Ok(patch) if !patch.is_empty() => patch,
Ok(_) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some("No commits between the branches to propose".into());
cx.notify();
})?;
return Ok(());
}
Err(error) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error =
Some(format!("Failed to generate the patch: {error}").into());
cx.notify();
})?;
return Ok(());
}
};
if let Err(error) = publish.await {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some(error.to_string().into());
cx.notify();
})?;
return Ok(());
}
this.update_in(cx, |this, window, cx| {
this.submitting = false;
store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
description,
Some(branch_name),
patch,
false,
Some(merge_base),
Some(repo_path),
cx,
);
});
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
@@ -1463,140 +1410,3 @@ impl Render for NewPullRequestView {
)
}
}
#[cfg(test)]
mod tests {
use nostr::prelude::*;
use signed_core::repo_addr;
use super::*;
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Build a signed kind-30617 event for `owner` with the given tags.
fn announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = repo_addr(
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"upstream",
);
// Newest first, as RepoListStore keeps them.
// Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
let all = vec![
announcements(
2,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
// The user's fork comes first, then the other author's.
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = repo_addr(base_owner, "upstream");
let mut all = vec![
announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
// Without a base EUC only `u`-tag forks match.
all.push(
announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["u-fork"]);
}
}
@@ -20,8 +20,11 @@ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind, Nip34Tag, Url};
use signed_core::{activity_subject, pull_request_patch};
use nostr::prelude::{Event, EventId, Kind};
use signed_core::{
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, GitStore, ProfileStore, RepoStore};
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
@@ -701,66 +704,6 @@ fn open_update_pull_request_dialog(
}
/// The `c` tag of a PR event, the commit the proposal points at.
fn current_commit_of(root: &Event) -> Option<String> {
root.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 a PR event, as hex.
///
/// The most recent common ancestor with the target branch.
fn merge_base_of(event: &Event) -> Option<String> {
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 a PR event.
///
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls),
_ => None,
})
}
/// The `branch-name` tag of a PR event, if any.
fn branch_name_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::BranchName(name)) => Some(name),
_ => None,
})
}
/// The latest PR update, kind 1619, revising `root`.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, 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)
}
/// One-line commit metadata for the commits list.
///
/// Author and relative time, whichever is available.
@@ -821,104 +764,9 @@ impl Render for PullRequestDetailView {
#[cfg(test)]
mod tests {
use nostr::prelude::{Tag, *};
use super::*;
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Build a signed event with a controlled `created_at`.
fn signed(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn reads_current_commit_and_branch_name() {
let pr = pr_root();
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
}
#[test]
fn returns_none_without_pr_tags() {
let pr = signed(Kind::GitPullRequest, vec![], 100);
assert_eq!(current_commit_of(&pr), None);
assert_eq!(branch_name_of(&pr), None);
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
// An update revising a different PR must be ignored even though it is newer.
let unrelated = signed(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.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());
}
#[test]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root).is_none());
}
#[test]
fn commit_meta_combines_author_and_time() {