update pull request
This commit is contained in:
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_nostr = { path = "../signed_nostr" }
|
||||
settings = { path = "../settings" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr.workspace = true
|
||||
@@ -24,3 +25,6 @@ log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls = "0.23"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1546,6 +1546,70 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url>
|
||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||
}
|
||||
|
||||
/// The GRASP-06 contributor namespace URL of a pull request tip on the
|
||||
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in
|
||||
/// the URL; the server stores it under the hex form). Anyone may push there;
|
||||
/// no announcement or maintainer rights are involved.
|
||||
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||
}
|
||||
|
||||
/// Assemble the `clone` URLs of a pull request: the author's GRASP-06
|
||||
/// `/prs/` URLs first (author-controlled, most likely to accept the tip
|
||||
/// push), then the base announcement's clone URLs, deduplicated while
|
||||
/// preserving that order.
|
||||
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
for url in prs_urls.into_iter().chain(base_clone_urls) {
|
||||
if seen.insert(url.to_string()) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
|
||||
/// Unparseable URLs are dropped (the UI only writes well-formed servers).
|
||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "g")
|
||||
.filter_map(|tag| tag.content())
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grasp servers of the newest kind-10317 grasp list among `events`
|
||||
/// (latest event wins, like every other latest-wins resolution in the app);
|
||||
/// empty when there is no list, so the caller falls back to the settings
|
||||
/// defaults.
|
||||
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
events
|
||||
.into_iter()
|
||||
.max_by_key(|event| event.created_at)
|
||||
.map(|event| grasp_list_servers(&event))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the user's published grasp servers: the `g` tags (in order) of
|
||||
/// their latest kind-10317 grasp list in the local database. Returns an
|
||||
/// empty list when the user has no published list, so the caller can fall
|
||||
/// back to the settings defaults.
|
||||
pub(crate) async fn user_grasp_list_servers(
|
||||
client: Client,
|
||||
user: PublicKey,
|
||||
) -> Result<Vec<RelayUrl>, Error> {
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filters::grasp_list(user))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
Ok(latest_grasp_list_servers(events))
|
||||
}
|
||||
|
||||
/// Push the repository at `path` to every grasp server: a server that
|
||||
/// rejects the push is logged, but the push only fails when no server
|
||||
/// accepted it. `push` performs the single-server push (e.g.
|
||||
@@ -1628,4 +1692,90 @@ mod tests {
|
||||
"https://gitnostr.com/npub1test/my-repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp06_prs_url_matches_ngit_format() {
|
||||
assert_eq!(
|
||||
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||
);
|
||||
// `ws://` grasp servers (local dev) keep their plain-HTTP base.
|
||||
assert_eq!(
|
||||
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_clone_urls_orders_author_first_and_deduplicates() {
|
||||
let prs = vec![
|
||||
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||
];
|
||||
let base = vec![
|
||||
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
|
||||
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||
];
|
||||
|
||||
let urls = pr_clone_urls(prs, base);
|
||||
assert_eq!(
|
||||
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec![
|
||||
"https://a.example/prs/npub1me/repo.git",
|
||||
"https://a.example/npub1owner/repo.git",
|
||||
"https://b.example/npub1owner/repo.git",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
|
||||
let keys = Keys::generate();
|
||||
let tags: Vec<Tag> = servers
|
||||
.iter()
|
||||
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::GitUserGraspList, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp_list_servers_reads_g_tags_in_order() {
|
||||
let event = grasp_list_event(
|
||||
&["wss://first.example", "wss://second.example", "not a url"],
|
||||
1000,
|
||||
);
|
||||
|
||||
let servers = grasp_list_servers(&event);
|
||||
assert_eq!(
|
||||
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec!["wss://first.example", "wss://second.example"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
|
||||
let old = grasp_list_event(&["wss://old.example"], 1000);
|
||||
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
|
||||
|
||||
// The newest list wins, its `g` order preserved.
|
||||
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
|
||||
assert_eq!(
|
||||
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec!["wss://fresh.example", "wss://also.example"]
|
||||
);
|
||||
|
||||
// The order of the input events does not matter.
|
||||
let servers = latest_grasp_list_servers(vec![fresh, old]);
|
||||
assert_eq!(
|
||||
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec!["wss://fresh.example", "wss://also.example"]
|
||||
);
|
||||
|
||||
// No list at all: empty, so the caller falls back to the defaults.
|
||||
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
//! Local checkout associations ("remember" tier of the PR suggestions):
|
||||
//! which local folders are checkouts of which announced repositories.
|
||||
//!
|
||||
//! Two sources feed the resolution:
|
||||
//!
|
||||
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
|
||||
//! recorded when the user clones a repository from the app or picks a
|
||||
//! folder in the New PR panel.
|
||||
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
|
||||
//! scanned repository whose `origin` URL matches an announcement `clone`
|
||||
//! URL (scheme-insensitive), or whose root commit equals an announcement
|
||||
//! EUC, is a checkout of that announced repository.
|
||||
//!
|
||||
//! The store also computes per-checkout "ready to contribute" statuses
|
||||
//! (branch, base and commits ahead) for the pull-request list banner.
|
||||
//! Everything is resolved on background threads and swapped in as
|
||||
//! [`Arc`]s; the UI never waits for git.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr::prelude::*;
|
||||
use settings::{CheckoutRecord, SettingsStore};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::git_store::GitStore;
|
||||
use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation, so bursts
|
||||
/// of notifications (settings edits, rescan ticks) collapse into one pass.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How often the statuses of open repository panels are refreshed, so a
|
||||
/// checkout committed to or pulled in external git surfaces in the banner
|
||||
/// without reopening the panel.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
const MAX_STATUS_CHECKOUTS: usize = 8;
|
||||
|
||||
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
|
||||
impl Global for GlobalCheckoutsStore {}
|
||||
|
||||
/// One associated local checkout of a repository, with the git facts needed
|
||||
/// to suggest a pull request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutStatus {
|
||||
/// The checkout folder.
|
||||
pub path: PathBuf,
|
||||
/// The branch checked out (`None`-less: detached checkouts are idle).
|
||||
pub branch: String,
|
||||
/// Commit the branch points at, for tip-based PR dedupe.
|
||||
pub head: String,
|
||||
/// The branch this checkout is compared against (announced HEAD branch,
|
||||
/// else `main`, else the first local branch).
|
||||
pub base: String,
|
||||
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
|
||||
pub ahead: u32,
|
||||
}
|
||||
|
||||
/// A remembered record, with the address already parsed.
|
||||
struct Remembered {
|
||||
path: PathBuf,
|
||||
addr: RepoAddr,
|
||||
last_used: u64,
|
||||
}
|
||||
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository: remembered records
|
||||
/// (freshest first) plus scanned repos matched implicitly, deduplicated
|
||||
/// by path. Missing directories are dropped before publishing.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Repositories whose statuses are recomputed whenever the inputs
|
||||
/// change (the repository detail panels currently open).
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// Announced head branch last provided per requested repository, so a
|
||||
/// recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl CheckoutsStore {
|
||||
/// Retrieve the global checkouts store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalCheckoutsStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalCheckoutsStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store: observe the inputs (settings records, the local
|
||||
/// scan, the announcement list) and resolve the associations right away.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let repos = RepoListStore::global(cx);
|
||||
|
||||
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
}
|
||||
|
||||
let mut store = Self {
|
||||
by_repo: Arc::new(HashMap::new()),
|
||||
statuses: Arc::new(HashMap::new()),
|
||||
status_requested: HashSet::new(),
|
||||
requested_head: HashMap::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
_subscriptions: subscriptions,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
store.refresh(cx);
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use: (re)insert the record with
|
||||
/// a fresh timestamp, so freshest-first ordering follows actual use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let last_used = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.update(cx, |settings, cx| {
|
||||
settings.edit(
|
||||
|s| {
|
||||
s.checkouts
|
||||
.records
|
||||
.retain(|r| !(r.path == path && r.addr == addr_str));
|
||||
s.checkouts.records.push(CheckoutRecord {
|
||||
path,
|
||||
addr: addr_str,
|
||||
last_used,
|
||||
});
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The associated checkouts of `addr`, freshest first. Empty when none
|
||||
/// are known (or the resolution has not run yet).
|
||||
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the "ready to contribute" statuses of `addr` to be kept
|
||||
/// current (called while the repository's detail panel is open).
|
||||
/// `announced_head` is the announced HEAD branch of the repository
|
||||
/// (from its state announcement), used to default the base.
|
||||
pub fn request_statuses(
|
||||
&mut self,
|
||||
addr: &RepoAddr,
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.status_requested.insert(addr.clone());
|
||||
if announced_head != self.requested_head.get(addr).cloned().flatten() {
|
||||
self.requested_head.insert(addr.clone(), announced_head);
|
||||
}
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-contribute statuses of `addr`; empty while none are
|
||||
/// known or nothing is ahead.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-resolve associations (and the requested statuses). Debounced:
|
||||
/// bursts of notifications collapse into one pass; requests arriving
|
||||
/// while a pass runs are folded into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One resolve + apply cycle (debounced entry point).
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
// Inputs snapshot, all cheap shared reads.
|
||||
let records = {
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.read(cx).settings().checkouts.records.clone()
|
||||
};
|
||||
let remembered: Vec<Remembered> = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
let addr = record.addr.parse::<RepoAddr>().ok()?;
|
||||
Some(Remembered {
|
||||
path: record.path,
|
||||
addr,
|
||||
last_used: record.last_used,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||
.status_requested
|
||||
.iter()
|
||||
.map(|addr| {
|
||||
(
|
||||
addr.clone(),
|
||||
self.requested_head.get(addr).cloned().flatten(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
// Read the git facts of every scanned repository off the main
|
||||
// thread: origin URL and root commit (both CLI reads).
|
||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||
for path in scanned.iter() {
|
||||
// The browser's mirror clones share the announce URLs and
|
||||
// EUCs; they are not user checkouts.
|
||||
if cache_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| path.starts_with(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let origin = signed_git::origin_url(path).ok().flatten();
|
||||
let root = signed_git::root_commit(path).ok().flatten();
|
||||
facts.push((path.clone(), origin, root));
|
||||
}
|
||||
|
||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||
// Missing directories are stale records; drop them.
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||
.collect();
|
||||
|
||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||
for (addr, announced_head) in &requested {
|
||||
let Some(paths) = associations.get(addr) else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<CheckoutStatus> = paths
|
||||
.iter()
|
||||
.take(MAX_STATUS_CHECKOUTS)
|
||||
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
statuses.insert(addr.clone(), list);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((associations, statuses))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (associations, statuses) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
// Git reads are best-effort; keep the last results.
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.by_repo = Arc::new(associations);
|
||||
this.statuses = Arc::new(statuses);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
// While any repository panel is open, keep its statuses
|
||||
// current: local commits, pulls and branch switches happen
|
||||
// outside the app and are not otherwise observable.
|
||||
this.update(cx, |this, cx| {
|
||||
if !this.status_requested.is_empty() && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(STATUS_POLL).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
this.tasks.push(task);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of a repository URL: host, explicit port and path with a
|
||||
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so
|
||||
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same
|
||||
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style
|
||||
/// or plain paths), which then compare by raw string.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
let mut path = parsed.path().trim_matches('/').to_owned();
|
||||
if let Some(stripped) = path.strip_suffix(".git") {
|
||||
path = stripped.to_owned();
|
||||
}
|
||||
Some((host, parsed.port(), path))
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository, ignoring the
|
||||
/// transport scheme (see [`url_identity`]).
|
||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced
|
||||
/// repositories: remembered records (freshest first per repository),
|
||||
/// followed by scanned repositories matched by origin URL or EUC.
|
||||
/// Deduplicated by path, keeping the first (remembered) occurrence.
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
announcements: impl IntoIterator<Item = &'a Announcement>,
|
||||
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
||||
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
||||
|
||||
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
||||
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
||||
for record in sorted {
|
||||
let paths = out.entry(record.addr.clone()).or_default();
|
||||
if !paths.contains(&record.path) {
|
||||
paths.push(record.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (path, origin, root) in scanned {
|
||||
for announcement in &announcements {
|
||||
let url_match = origin.as_deref().is_some_and(|origin| {
|
||||
announcement
|
||||
.clone
|
||||
.iter()
|
||||
.any(|url| same_repo_url(origin, url.as_str()))
|
||||
});
|
||||
let euc_match = root
|
||||
.as_deref()
|
||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||
if url_match || euc_match {
|
||||
let paths = out.entry(announcement.addr()).or_default();
|
||||
if !paths.contains(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the worktree of `path` has uncommitted changes (a dirty
|
||||
/// checkout is never suggested: the proposal should cover committed work).
|
||||
fn worktree_dirty(path: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["status", "--porcelain"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `path` (`git rev-list
|
||||
/// --count`); `0` when the range is empty or cannot be computed.
|
||||
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["rev-list", "--count", &format!("{base}..{branch}")])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) => String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch checked out at `path` (`git branch --show-current`), `None`
|
||||
/// when detached.
|
||||
fn current_branch_of(path: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["branch", "--show-current"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.ok()?;
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
(!branch.is_empty()).then_some(branch)
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout, or `None` when it is
|
||||
/// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of
|
||||
/// its base. The base defaults like the New PR panel: the announced HEAD
|
||||
/// branch when the checkout has it, else `main`, else the first branch.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
if branches.is_empty() || worktree_dirty(path) {
|
||||
return None;
|
||||
}
|
||||
let branch = current_branch_of(path)?;
|
||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||
let base = announced_head
|
||||
.filter(|name| branches.iter().any(|b| b == name))
|
||||
.map(str::to_owned)
|
||||
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
|
||||
.or_else(|| branches.first().cloned())?;
|
||||
if base == branch {
|
||||
return None;
|
||||
}
|
||||
let ahead = commits_ahead(path, &base, &branch);
|
||||
(ahead > 0).then_some(CheckoutStatus {
|
||||
path: path.to_path_buf(),
|
||||
branch,
|
||||
head,
|
||||
base,
|
||||
ahead,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
|
||||
/// caller) already proposes the same change as `checkout`: authored by
|
||||
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
|
||||
/// `c` tip tag matching the checkout's HEAD commit.
|
||||
pub fn pr_proposes_checkout(
|
||||
pr: &Event,
|
||||
open: bool,
|
||||
user: PublicKey,
|
||||
checkout: &CheckoutStatus,
|
||||
) -> bool {
|
||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||
return false;
|
||||
}
|
||||
let branch_matches = pr
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind() == "branch-name")
|
||||
.and_then(|t| t.content())
|
||||
.is_some_and(|name| name == checkout.branch);
|
||||
// A renamed branch falls back to the proposed tip commit.
|
||||
let tip_matches = pr
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind() == "c")
|
||||
.and_then(|t| t.content())
|
||||
.is_some_and(|tip| tip == checkout.head);
|
||||
branch_matches || tip_matches
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use signed_core::{RepoAddr, repo_addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn same_repo_url_ignores_the_transport_scheme() {
|
||||
// grasp announce vs https origin, with and without `.git`.
|
||||
assert!(same_repo_url(
|
||||
"grasp://relay.ngit.dev/npub1test/repo",
|
||||
"https://relay.ngit.dev/npub1test/repo.git"
|
||||
));
|
||||
assert!(same_repo_url(
|
||||
"ws://localhost:8080/npub1test/repo",
|
||||
"http://localhost:8080/npub1test/repo"
|
||||
));
|
||||
// The port and the path matter.
|
||||
assert!(!same_repo_url(
|
||||
"wss://localhost:8081/npub1test/repo",
|
||||
"wss://localhost:8080/npub1test/repo"
|
||||
));
|
||||
assert!(!same_repo_url(
|
||||
"wss://host/npub1test/repo",
|
||||
"wss://host/npub1other/repo"
|
||||
));
|
||||
// Unparseable URLs compare literally.
|
||||
assert!(same_repo_url("/local/path", "/local/path"));
|
||||
assert!(!same_repo_url("/local/path", "/local/other"));
|
||||
}
|
||||
|
||||
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
|
||||
Remembered {
|
||||
path: PathBuf::from(path),
|
||||
addr: addr(id),
|
||||
last_used,
|
||||
}
|
||||
}
|
||||
|
||||
fn scanned(
|
||||
path: &str,
|
||||
origin: Option<&str>,
|
||||
root: Option<&str>,
|
||||
) -> (PathBuf, Option<String>, Option<String>) {
|
||||
(
|
||||
PathBuf::from(path),
|
||||
origin.map(str::to_owned),
|
||||
root.map(str::to_owned),
|
||||
)
|
||||
}
|
||||
|
||||
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
|
||||
fn owner() -> PublicKey {
|
||||
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
|
||||
}
|
||||
|
||||
fn addr(id: &str) -> RepoAddr {
|
||||
repo_addr(owner(), id)
|
||||
}
|
||||
|
||||
/// Build one announcement by the fixed test owner with `clone` URLs and
|
||||
/// an EUC.
|
||||
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
|
||||
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
|
||||
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
|
||||
for url in clones {
|
||||
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
|
||||
}
|
||||
if let Some(euc) = euc {
|
||||
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
|
||||
}
|
||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("signed");
|
||||
Announcement::from_event(&event).expect("parsed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_orders_remembered_freshest_first() {
|
||||
let announcements = vec![announcement("repo", &[], None)];
|
||||
let base = addr("repo");
|
||||
|
||||
let resolved = resolve_associations(
|
||||
&[
|
||||
remembered("/old", "repo", 100),
|
||||
remembered("/fresh", "repo", 200),
|
||||
remembered("/other", "unrelated", 300),
|
||||
],
|
||||
&[],
|
||||
&announcements,
|
||||
);
|
||||
|
||||
let paths = resolved.get(&base).expect("associations");
|
||||
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
|
||||
// Records for repositories without announcements stay inert.
|
||||
assert_eq!(resolved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_matches_scanned_repos_by_origin_and_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let announcements = vec![
|
||||
announcement("repo", &["grasp://host/npub1x/repo"], None),
|
||||
announcement("family", &[], Some(euc)),
|
||||
];
|
||||
let repo = addr("repo");
|
||||
let family = addr("family");
|
||||
|
||||
let resolved = resolve_associations(
|
||||
&[],
|
||||
&[
|
||||
// Origin matches modulo scheme and the `.git` suffix.
|
||||
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
|
||||
// Root commit matches the family EUC.
|
||||
scanned("/family-checkout", None, Some(euc)),
|
||||
// Neither matches anything.
|
||||
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
|
||||
],
|
||||
&announcements,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved.get(&repo).expect("repo matches"),
|
||||
&vec![PathBuf::from("/clone")]
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.get(&family).expect("family matches"),
|
||||
&vec![PathBuf::from("/family-checkout")]
|
||||
);
|
||||
assert_eq!(resolved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_deduplicates_paths_remembering_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let announcements = vec![announcement(
|
||||
"repo",
|
||||
&["https://host/npub1x/repo.git"],
|
||||
Some(euc),
|
||||
)];
|
||||
let base = addr("repo");
|
||||
|
||||
// The same path is both remembered and scanned (its origin matches);
|
||||
// the remembered occurrence wins and it is listed once.
|
||||
let resolved = resolve_associations(
|
||||
&[remembered("/shared", "repo", 100)],
|
||||
&[
|
||||
scanned("/shared", Some("https://host/npub1x/repo"), None),
|
||||
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
|
||||
],
|
||||
&announcements,
|
||||
);
|
||||
|
||||
let paths = resolved.get(&base).expect("associations");
|
||||
assert_eq!(
|
||||
paths,
|
||||
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_status_reports_ahead_branches_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init");
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&path)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
let commit = |message: &str| {
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", message]);
|
||||
};
|
||||
|
||||
// A feature branch ahead of main: ready to contribute.
|
||||
run(&["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||
commit("feature work");
|
||||
let status = checkout_status(&path, Some("main")).expect("status");
|
||||
assert_eq!(status.branch, "feature");
|
||||
assert_eq!(status.base, "main");
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
// Dirty worktrees are never suggested.
|
||||
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
|
||||
assert!(checkout_status(&path, Some("main")).is_none());
|
||||
run(&["checkout", "--", "."]);
|
||||
|
||||
// Even with main: nothing to propose.
|
||||
run(&["checkout", "main"]);
|
||||
assert_eq!(checkout_status(&path, Some("main")), None);
|
||||
}
|
||||
|
||||
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::GitPullRequest, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn status(branch: &str, head: &str) -> CheckoutStatus {
|
||||
CheckoutStatus {
|
||||
path: PathBuf::from("/checkout"),
|
||||
branch: branch.to_owned(),
|
||||
head: head.to_owned(),
|
||||
base: "main".to_owned(),
|
||||
ahead: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_proposes_checkout_matches_branch_or_tip() {
|
||||
let author = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
|
||||
// A matching `branch-name` covers the proposal.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
|
||||
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Without the branch name (renamed), the `c` tip still matches.
|
||||
let pr = pr_event(
|
||||
author,
|
||||
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
|
||||
);
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Someone else's PR, a closed PR, a different branch and a missing
|
||||
// tip all leave the checkout uncovered.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"]]);
|
||||
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
|
||||
let other = pr_event(
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
&[&["branch-name", "feature"]],
|
||||
);
|
||||
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
|
||||
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
|
||||
assert!(!pr_proposes_checkout(
|
||||
&other_branch,
|
||||
true,
|
||||
other_branch.pubkey,
|
||||
&status
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
@@ -8,6 +9,7 @@ mod repo_list;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
pub use local_repos::LocalReposStore;
|
||||
@@ -22,7 +24,7 @@ pub use utils::shorten_pubkey;
|
||||
/// Call once at startup, before opening any window that uses the stores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
@@ -38,15 +40,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Seed the explore list from the local database; relay syncs continue
|
||||
// in the background.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
// The clone cache is native-only; wasm registers an empty store so
|
||||
// `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
|
||||
entity
|
||||
}
|
||||
@@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
entity
|
||||
}
|
||||
|
||||
+112
-33
@@ -14,7 +14,9 @@ use signed_core::{
|
||||
subject_override,
|
||||
};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, grasp_base_url};
|
||||
use crate::backend::{
|
||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
||||
};
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||
@@ -645,11 +647,15 @@ impl RepoStore {
|
||||
/// parsed from the series' last `From <commit>` header (the tip); without
|
||||
/// one publishing is refused, because the PR's `c` tag must carry a real
|
||||
/// commit id for other NIP-34 clients to verify and apply the proposal.
|
||||
/// The `clone` tag carries the announced mirror URLs, and when
|
||||
/// `push_from` is set the tip is pushed to those servers under
|
||||
/// `refs/nostr/<event-id>` (best-effort) before the PR is published, so
|
||||
/// the commit is actually downloadable there; the linked patch stays the
|
||||
/// source of truth either way.
|
||||
///
|
||||
/// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first
|
||||
/// (resolved from their kind-10317 grasp list, falling back to the
|
||||
/// settings defaults) plus the announced mirror URLs, so the tip is
|
||||
/// downloadable on the author's own hosting even when the base project
|
||||
/// accepts nothing. When `push_from` is set, the tip is pushed to those
|
||||
/// servers under `refs/nostr/<event-id>` (best-effort, author servers
|
||||
/// first) before the PR is published; the linked patch stays the source
|
||||
/// of truth either way.
|
||||
///
|
||||
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
|
||||
/// publishes a kind-1633 status right after the PR event. `merge_base`
|
||||
@@ -704,28 +710,44 @@ impl RepoStore {
|
||||
let backend = Backend::global(cx);
|
||||
let signer = backend.read(cx).signer();
|
||||
|
||||
if backend.read(cx).current_user().is_none() {
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.last_error = Some("Sign in to open a pull request".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The author's npub names their GRASP-06 namespace (`/prs/...`).
|
||||
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
|
||||
|
||||
let addr = self.addr.clone();
|
||||
let owner = self.addr.public_key;
|
||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
|
||||
let (push_owner, push_repo_id, push_relays) = self
|
||||
let repo_id = addr.identifier.clone();
|
||||
let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex());
|
||||
let push_relays = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| {
|
||||
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
|
||||
(owner, a.id.clone(), a.relays.clone())
|
||||
})
|
||||
.map(|a| a.relays.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// GRASP-06 hosting falls back to the settings defaults when
|
||||
// the author has no published grasp list
|
||||
let defaults: Vec<RelayUrl> = {
|
||||
let settings = settings::SettingsStore::global(cx).read(cx).settings();
|
||||
let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() {
|
||||
settings::DEFAULT_GRASP_SERVERS
|
||||
.iter()
|
||||
.map(|url| (*url).to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
settings.grasp_servers.default_servers.clone()
|
||||
};
|
||||
urls.iter()
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// The PR references the root patch event so viewers can find
|
||||
// the patch without carrying it inline.
|
||||
// The PR references the root patch event so viewers can find the patch without carrying it inline.
|
||||
let root_patch = match publish_patch_series(
|
||||
&this,
|
||||
cx,
|
||||
@@ -747,20 +769,76 @@ impl RepoStore {
|
||||
}
|
||||
};
|
||||
|
||||
// GRASP-06: the tip is pushed to the author's own grasp servers
|
||||
// under `/prs/<author-npub>/<repo-id>.git`, so contributing to
|
||||
// someone else's project never depends on their servers
|
||||
// accepting the push. Resolve them from the author's latest
|
||||
// kind-10317 grasp list; the settings defaults stand in when no
|
||||
// list is published (or the query fails).
|
||||
let author_servers = {
|
||||
let query = this.update(cx, |_this, cx| {
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
user_grasp_list_servers(client, user)
|
||||
})?;
|
||||
match cx.background_spawn(query).await {
|
||||
Ok(published) if !published.is_empty() => published,
|
||||
_ => defaults,
|
||||
}
|
||||
};
|
||||
|
||||
let author_targets: Vec<(String, String)> = {
|
||||
let mut targets = Vec::new();
|
||||
for server in &author_servers {
|
||||
let Some(base) = grasp_base_url(server) else {
|
||||
continue;
|
||||
};
|
||||
let url = grasp06_prs_url(&base, &author_npub, &repo_id);
|
||||
if !targets.iter().any(|(existing, _)| existing == &url) {
|
||||
targets.push((url, server.to_string()));
|
||||
}
|
||||
}
|
||||
targets
|
||||
};
|
||||
let base_targets: Vec<(String, String)> = {
|
||||
let mut targets = Vec::new();
|
||||
for relay in &push_relays {
|
||||
let Some(base) = grasp_base_url(relay) else {
|
||||
continue;
|
||||
};
|
||||
let url = format!("{base}/{base_npub}/{repo_id}.git");
|
||||
if !targets.iter().any(|(existing, _)| existing == &url) {
|
||||
targets.push((url, relay.to_string()));
|
||||
}
|
||||
}
|
||||
targets
|
||||
};
|
||||
|
||||
let builder = this.update(cx, |this, _cx| {
|
||||
// NIP-34: PRs carry at least one clone URL where the tip
|
||||
// commit can be downloaded. The author's `/prs/` URLs come
|
||||
// first (author-controlled, most likely alive), then the
|
||||
// announced mirrors. The list is fixed before signing: the
|
||||
// pushed ref name embeds the event id, so every candidate
|
||||
// URL is listed up front; dead URLs are inert, the linked
|
||||
// patch stays the source of truth.
|
||||
let prs_urls: Vec<Url> = author_targets
|
||||
.iter()
|
||||
.filter_map(|(url, _)| Url::parse(url).ok())
|
||||
.collect();
|
||||
let base_clone = this
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.clone())
|
||||
.unwrap_or_default();
|
||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||
|
||||
let builder = GitPullRequest {
|
||||
repository: this.addr.clone(),
|
||||
content: description,
|
||||
subject,
|
||||
labels: Vec::new(),
|
||||
branch_name,
|
||||
// NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded,
|
||||
// the announced mirrors are also the servers the tip is pushed to below.
|
||||
clone: this
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.clone())
|
||||
.unwrap_or_default(),
|
||||
clone,
|
||||
current_commit,
|
||||
root_patch_event: Some(root_patch.id),
|
||||
merge_base: merge_base
|
||||
@@ -793,20 +871,21 @@ impl RepoStore {
|
||||
let path = path.clone();
|
||||
let tip = tip.clone();
|
||||
let reference = reference.clone();
|
||||
let owner = push_owner.clone();
|
||||
let repo_id = push_repo_id.clone();
|
||||
let relays = push_relays.clone();
|
||||
// Author servers first, then the base repository's
|
||||
// announced grasp servers (best-effort redundancy).
|
||||
let targets: Vec<(String, String)> = author_targets
|
||||
.into_iter()
|
||||
.chain(base_targets)
|
||||
.collect();
|
||||
async move {
|
||||
let mut failures = Vec::new();
|
||||
let mut pushed = 0;
|
||||
for relay in &relays {
|
||||
let Some(base) = grasp_base_url(relay) else {
|
||||
continue;
|
||||
};
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
|
||||
for (url, label) in &targets {
|
||||
match signed_git::push_commit_ref(
|
||||
&path, url, &tip, &reference,
|
||||
) {
|
||||
Ok(()) => pushed += 1,
|
||||
Err(e) => failures.push(format!("{relay}: {e}")),
|
||||
Err(e) => failures.push(format!("{label}: {e}")),
|
||||
}
|
||||
}
|
||||
(pushed, failures)
|
||||
|
||||
Reference in New Issue
Block a user