strip redundant doc comments from signed_core and signed_git

This commit is contained in:
2026-10-03 16:31:37 +07:00
parent 6706e328ec
commit a56b0b8881
31 changed files with 265 additions and 1031 deletions
+18 -62
View File
@@ -21,7 +21,6 @@ use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url};
use crate::repos::RepoListStore;
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
@@ -29,13 +28,9 @@ const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub enum BackendEvent {
SignerRequired,
/// The stored identity is NIP-49 encrypted key.
PassphraseRequired,
/// The signer changed on login, logout or account switch.
SignerChanged,
/// Kind-0 metadata arrived for these authors; re-read them from the store.
ProfileUpdates(Vec<PublicKey>),
/// Repository events arrived: announcements, states, activity and deletions.
RepoUpdates(Vec<Update>),
Synced,
Error(String),
@@ -55,7 +50,6 @@ pub struct Backend {
signer: UniversalSigner,
current_user: Option<PublicKey>,
inbox: Entity<Inbox>,
/// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool,
pushing_repos: Entity<HashSet<RepoAddr>>,
}
@@ -158,10 +152,6 @@ impl Backend {
}
}
/// Restore the saved session from the Keyring.
///
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
/// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired);
@@ -226,7 +216,6 @@ impl Backend {
task.detach();
}
/// Decrypt the NIP-49 keyring credential with the given passphrase.
pub fn restore_with_passphrase(
&mut self,
password: &str,
@@ -295,7 +284,6 @@ impl Backend {
write.await?;
this.update(cx, |this, cx| {
// Become the new identity so later publishes are signed with the new keys.
this.signer.swap_inner(keys);
this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx);
@@ -352,9 +340,6 @@ impl Backend {
})
}
/// Initialize a local clone with a `main` branch and a `README.md`.
///
/// The task yields the announcement and the path of the working copy.
pub fn create_repository(
&mut self,
name: &str,
@@ -400,7 +385,6 @@ impl Backend {
let servers = grasp_servers.clone();
let client = self.client.clone();
// Initialize directly at the user's chosen destination.
let destination = {
let dir_name = GitCache::sanitize_path_component(&name);
let dir_name = if dir_name.is_empty() {
@@ -473,7 +457,6 @@ impl Backend {
})
}
/// Publish an existing local repository to NIP-34.
pub fn publish_local_repo(
&mut self,
path: PathBuf,
@@ -533,7 +516,6 @@ impl Backend {
let announcement =
repository_announcement(&repo_id, &name, &description, &owner, &servers, euc);
// The state event is the push authorization. It must be accepted before the push below.
let event = announce_repository_and_push(
&this,
&client,
@@ -550,7 +532,6 @@ impl Backend {
)
.await?;
// Point `origin` at the first grasp server so later pushes have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone();
@@ -562,8 +543,8 @@ impl Backend {
.await;
}
// Record the ngit-compatible `nostr.repo` marker,
// so the next scan detects the repository instead of offering to publish it again.
// The ngit-compatible `nostr.repo` marker makes the next scan
// detect the repository instead of offering to publish it again.
let coordinate = RepoAddr::new(event.pubkey, repo_id.clone());
match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() {
Ok(naddr) => {
@@ -587,10 +568,8 @@ impl Backend {
})
}
/// Re-push the repository's current refs to the grasp servers in its `relays` tag.
///
/// Errors when no grasp server accepted the push, the outcome reports
/// which servers did when only some accepted it.
// Errors when no grasp server accepted the push; the outcome reports
// which servers did when only some accepted it.
pub fn push_repository(
&mut self,
announcement: Announcement,
@@ -600,10 +579,6 @@ impl Backend {
self.push_repo_from(announcement, path, None, cx)
}
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
/// The checkout is the working copy of the user's own repository.
///
/// Publish a fresh state event, then push every branch and tag of the checkout.
pub fn push_checkout(
&mut self,
announcement: Announcement,
@@ -614,7 +589,6 @@ impl Backend {
self.push_repo_from(announcement, checkout, announced_head, cx)
}
/// Shared body of the mirror-based and checkout-based pushes.
fn push_repo_from(
&mut self,
announcement: Announcement,
@@ -640,8 +614,8 @@ impl Backend {
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// Held for the whole task. Runs on completion, on error and on
// cancellation alike, since dropping the task drops this guard.
// Runs on completion, on error and on cancellation alike; the
// guard would be dropped with the task if not held.
let _guard = cx.on_drop(&this, {
let addr = addr.clone();
move |backend, cx| {
@@ -660,10 +634,8 @@ impl Backend {
work.await?
};
// The state event announces the pushed refs.
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
//
// Otherwise `HEAD` stays the checkout's current branch.
// Keep the announced default branch in `HEAD` when it is among
// the pushed refs; otherwise `HEAD` stays the checkout's branch.
let heads: Vec<&str> = state
.refs
.iter()
@@ -676,9 +648,8 @@ impl Backend {
state.head = Some(head);
}
// Grasp servers authorize a push by the state event they hold in purgatory.
// Stage the state event on each server's own relay, then push the git data,
// retrying transient purgatory denials.
// Grasp servers authorize a push by the state event they hold in
// purgatory: stage it on each server's relay before the git push.
let refs = state.refs.clone();
let head = state.head.clone();
@@ -725,8 +696,6 @@ impl Backend {
}
// Fan the state out to the relays once a git server holds the objects.
// Staging already stored the event locally, publishing notifies
// the repository views and other relays and clients.
if let Some(state_event) = &outcome.state_event
&& let Err(e) = client.send_event(state_event).broadcast().await
{
@@ -737,9 +706,6 @@ impl Backend {
})
}
/// Delete the repository from nostr.
///
/// Only the repository owner may delete it.
pub fn delete_repository(
&mut self,
addr: RepoAddr,
@@ -888,7 +854,6 @@ impl Backend {
.detach();
}
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
@@ -926,7 +891,6 @@ impl Backend {
.detach();
}
/// Sync filters through the SDK's NIP-65 gossip targeting.
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -958,7 +922,6 @@ impl Backend {
.detach();
}
/// Sync several bootstrap filters in order, within a single task.
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -997,10 +960,8 @@ impl Backend {
.detach();
}
/// Publish a NIP-09 deletion for each of `events`, best-effort.
///
/// Each target gets its own deletion event: a relay rejecting or
/// dropping one does not affect the others.
// Each target gets its own deletion event: a relay rejecting or dropping
// one does not affect the others.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
let pusher = GraspPush::new(self.client.clone(), self.signer.clone());
@@ -1017,7 +978,6 @@ impl Backend {
}
}
/// The announcement of one repository, as published to the relays.
fn repository_announcement(
repo_id: &str,
name: &str,
@@ -1041,7 +1001,10 @@ fn repository_announcement(
}
}
/// Announce a repository, stage the announcement on each grasp server's relay
// Announce the repository, then stage the state event and push: the state
// event is the push authorization, so it must be accepted before the push.
// Connect to all servers first — the nostr client queues events until each
// relay is connected.
#[allow(clippy::too_many_arguments)]
async fn announce_repository_and_push(
backend: &WeakEntity<Backend>,
@@ -1057,12 +1020,10 @@ async fn announce_repository_and_push(
cx: &mut AsyncApp,
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static,
) -> Result<Event, Error> {
// The nostr client queues events until each relay is connected.
for url in servers {
client.add_relay(url).and_connect().await.ok();
}
// The state event is the push authorization. It must be accepted before the push below.
let event = {
let builder = announcement.into_event_builder();
let event = builder.finalize_async(signer).await?;
@@ -1102,8 +1063,8 @@ async fn announce_repository_and_push(
};
if outcome.accepted() == 0 {
// The announcement is already published. Retract it so the
// repository is not left announced without content.
// Retract the announcement so the repository is not left announced
// without content.
backend
.update(cx, |backend, cx| {
backend.retract_events(std::slice::from_ref(&event), cx);
@@ -1118,8 +1079,6 @@ async fn announce_repository_and_push(
}
// Fan the state out to the relays once a git server holds the objects.
// Staging already stored the event locally, publishing makes it
// visible to the other relays and clients.
if let Some(state_event) = &outcome.state_event
&& let Err(e) = client.send_event(state_event).broadcast().await
{
@@ -1129,14 +1088,12 @@ async fn announce_repository_and_push(
Ok(event)
}
/// A relay event the backend routes to a store group.
enum UpdateEvent {
Profile(PublicKey),
Repo(Update),
}
impl UpdateEvent {
/// Await the next relay event from the notification stream.
async fn next(
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
seen: &mut HashSet<EventId>,
@@ -1167,7 +1124,6 @@ impl UpdateEvent {
}
}
/// Split a stored bunker credential into the plain URI and the session key.
fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") {
Some((base, nsec)) => {
-5
View File
@@ -7,12 +7,9 @@ use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*;
use signed_core::Filters;
/// Relays connected at startup, before any user-specific relay config is known.
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
/// Relays used to index the user's NIP-65 relay list.
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// Add and connect the startup relays.
async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
for url in BOOTSTRAP_RELAYS {
client.add_relay(url).and_connect().await?;
@@ -65,7 +62,6 @@ pub(crate) async fn sync_bootstrap_only(
Ok(output.value)
}
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
event
.tags
@@ -76,7 +72,6 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
.collect()
}
/// Grasp servers of the newest kind-10317 grasp list among `events`.
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
events
.into_iter()
+17 -98
View File
@@ -18,11 +18,8 @@ use crate::repos::RepoListStore;
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses are recomputed against the local refs.
const LOCAL_POLL: Duration = Duration::from_secs(2);
/// How often a full pass refreshes the remotes while any repository panel is open.
const STATUS_POLL: Duration = Duration::from_secs(15);
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
const PUSH_POLL: Duration = Duration::from_secs(60);
const MAX_STATUS_CHECKOUTS: usize = 8;
@@ -31,63 +28,37 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
impl Global for GlobalCheckoutsStore {}
/// One associated local checkout of a repository.
///
/// Carries the git facts needed to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus {
pub path: PathBuf,
/// The branch checked out. A detached checkout is idle and yields no status.
// A detached checkout is idle and yields no status.
pub branch: String,
/// Commit the branch points at, for tip-based PR dedupe.
// For tip-based PR dedupe.
pub head: String,
/// What the branch is compared against.
///
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
// `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
pub base: String,
/// Commits in `base..branch`.
///
/// Zero-ahead checkouts are dropped, so this is always above zero.
// Zero-ahead checkouts are dropped, so always above zero.
pub ahead: u32,
}
/// A remembered record, with the address already parsed.
struct Remembered {
path: PathBuf,
addr: RepoAddr,
last_used: u64,
}
/// Global store of local-checkout associations and per-checkout statuses.
pub struct CheckoutsStore {
/// Checkout paths per announced repository.
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
/// Ready-to-contribute statuses of the requested repositories.
///
/// Those are the repository detail panels currently open.
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Repositories whose statuses are recomputed on every input change.
///
/// Those are the repository detail panels currently open.
status_requested: HashSet<RepoAddr>,
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
///
/// The sidebar rows of the user's own repositories and their detail panels.
push_requested: HashSet<RepoAddr>,
/// The ready-to-push statuses of the requested own repositories.
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Last announced head branch per requested repository.
///
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
refresh: RefreshGate,
/// True while the timer between a scheduled refresh and its run is pending.
debounce_pending: bool,
local_pending: bool,
/// When the last full pass (with a remote refresh) completed.
///
/// The local pass runs a full pass again once this is older than the
/// reconciliation cadence, so remote moves still land.
// The local pass runs a full pass again once this is older than the
// reconciliation cadence, so remote moves still land.
last_full_sync: Option<Instant>,
_subscriptions: Vec<Subscription>,
}
@@ -189,17 +160,10 @@ impl CheckoutsStore {
});
}
/// The associated checkouts of `addr`, freshest first.
///
/// Empty when none are known or the resolution has not run yet.
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
self.by_repo.get(addr).cloned().unwrap_or_default()
}
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
/// Called while the repository's detail panel is open.
///
/// `announced_head` is the announced HEAD branch, used to default the base.
pub fn request_statuses(
&mut self,
addr: &RepoAddr,
@@ -213,26 +177,18 @@ impl CheckoutsStore {
self.refresh(cx);
}
/// The ready-to-contribute statuses of `addr`.
///
/// Empty while none are known or nothing is ahead.
pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.statuses.get(addr).cloned().unwrap_or_default()
}
/// Ask for the `ready to push` statuses of `addr` to stay current.
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
self.push_requested.insert(addr.clone());
self.refresh(cx);
}
/// The checkout at `path` was just pushed to the remote.
///
/// Its ready-to-push status is obsolete. Drop it from the cached statuses
/// and notify observers right away, so the sidebar badge and the push
/// banner update immediately instead of waiting for the next background
/// pass, which re-scans and re-fetches the remote. The debounced refresh
/// reconciles the remaining checkouts of the repository afterwards.
// Drop the stale ready-to-push status and notify observers right away, so
// the sidebar badge updates immediately instead of waiting for the next
// background pass. The debounced refresh reconciles the remaining checkouts.
pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context<Self>) {
let mut removed = false;
@@ -255,9 +211,6 @@ impl CheckoutsStore {
self.request_push_statuses(addr, cx);
}
/// The ready-to-push statuses of `addr`.
///
/// Empty while none are known or nothing is unpushed.
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
@@ -269,7 +222,6 @@ impl CheckoutsStore {
.unwrap_or(0)
}
/// Re-resolve the associations and the requested statuses.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
return;
@@ -284,7 +236,6 @@ impl CheckoutsStore {
.detach();
}
/// One full resolve and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.debounce_pending = false;
self.refresh.begin();
@@ -325,14 +276,11 @@ impl CheckoutsStore {
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main thread.
//
// The facts are the origin URL and the root commit, both CLI reads.
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
for scanned in scanned.iter() {
let path = &scanned.path;
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
// The browser's mirror clones are not user checkouts.
if cache_root
.as_ref()
.is_some_and(|root| path.starts_with(root))
@@ -353,7 +301,6 @@ impl CheckoutsStore {
let associations =
CheckoutsStore::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()))
@@ -369,7 +316,6 @@ impl CheckoutsStore {
let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results,
Err(_) => {
// Git reads are best-effort, keep the last results.
return this.update(cx, |this, cx| {
this.refresh.abort();
if poll {
@@ -388,8 +334,7 @@ impl CheckoutsStore {
this.statuses = statuses;
this.push_statuses = push_statuses;
// Notify only when something actually changed, so observers
// skip the no-op heartbeats.
// Notify only when something actually changed.
if associations_changed || statuses_changed || push_statuses_changed {
cx.notify();
}
@@ -402,9 +347,6 @@ impl CheckoutsStore {
this.update(cx, |this, cx| this.refresh(cx))?;
}
// Restart the fast local pass so the freshly resolved
// associations drive it. The pass itself decides when the next
// full pass runs.
this.update(cx, |this, cx| {
if poll {
this.schedule_local_pass(cx);
@@ -416,7 +358,6 @@ impl CheckoutsStore {
.detach();
}
/// Schedule the fast local status pass, unless one is already pending.
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
if self.local_pending {
return;
@@ -433,20 +374,17 @@ impl CheckoutsStore {
.detach();
}
/// The fast local status pass.
fn local_tick(&mut self, cx: &mut Context<Self>) {
// Nothing watched: the chain idles out until a new request restarts it.
// Nothing watched: the pass idles until a new request restarts it.
if self.status_requested.is_empty() && self.push_requested.is_empty() {
return;
}
// A full pass or a fresh request covers this tick, skip it.
if self.refresh.running() || self.debounce_pending {
self.schedule_local_pass(cx);
return;
}
// Open panels get the faster remote cadence.
let cadence = if self.status_requested.is_empty() {
PUSH_POLL
} else {
@@ -467,7 +405,6 @@ impl CheckoutsStore {
self.schedule_local_pass(cx);
}
/// Recompute the requested statuses against the tracking refs only.
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
let associations = self.by_repo.clone();
@@ -492,13 +429,12 @@ impl CheckoutsStore {
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let Ok((statuses, push_statuses)) = work.await else {
// Git reads are best-effort, keep the last results.
return Ok(());
};
this.update(cx, |this, cx| {
// A full pass or a fresh request will apply fresher data
// (the tracking refs move only when a full pass fetches).
// The tracking refs move only when a full pass fetches; a full
// pass or a fresh request will apply fresher data.
if this.refresh.running() || this.debounce_pending {
return;
}
@@ -593,7 +529,7 @@ impl CheckoutsStore {
})
}
/// The `ready to push` status of one checkout of the user's own repository.
// Never fetches the checked-out refs; reads the tracking refs as-is.
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
let repo = Repo::try_open(path)?;
if repo.is_dirty() {
@@ -611,8 +547,8 @@ impl CheckoutsStore {
let remote = format!("refs/remotes/origin/{branch}");
// A branch never fetched or pushed yet compares against the remote HEAD.
// The remote HEAD is the fork point in practice.
// A branch never fetched or pushed yet compares against the remote
// HEAD, the fork point in practice.
let base = if repo.ref_exists(&remote) {
remote
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
@@ -632,7 +568,6 @@ impl CheckoutsStore {
})
}
/// Compute the requested statuses against the checkout paths of `associations`.
fn compute_statuses(
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
requested: &[(RepoAddr, Option<String>)],
@@ -716,7 +651,6 @@ mod tests {
#[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"
@@ -725,7 +659,6 @@ mod tests {
"ws://localhost:8080/npub1test/repo",
"http://localhost:8080/npub1test/repo"
));
// The port and the path matter.
assert!(!same_repo_url(
"wss://localhost:8081/npub1test/repo",
"wss://localhost:8080/npub1test/repo"
@@ -734,7 +667,6 @@ mod tests {
"wss://host/npub1test/repo",
"wss://host/npub1other/repo"
));
// Unparseable URLs compare literally.
assert!(same_repo_url("/local/path", "/local/path"));
assert!(!same_repo_url("/local/path", "/local/other"));
}
@@ -762,7 +694,6 @@ mod tests {
run(&["commit", "-m", message]);
};
// A feature branch ahead of main, ready to contribute.
run(&["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
commit("feature work");
@@ -772,22 +703,16 @@ mod tests {
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!(CheckoutsStore::checkout_status(&path, Some("main")).is_none());
run(&["checkout", "--", "."]);
// Even on main, nothing to propose.
run(&["checkout", "main"]);
assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None);
}
#[test]
fn checkout_push_status_counts_unpushed_commits_only() {
// The `grasp remote` is a plain repository the checkout clones from.
// Its origin URL is a local path, so the whole cycle runs offline.
// Git refuses pushes to a checked-out branch by default.
// Act like a grasp server and allow them.
let dir = tempfile::tempdir().expect("tempdir");
let remote = dir.path().join("remote");
Repo::init(&remote, "My Repo", "").expect("init");
@@ -827,7 +752,6 @@ mod tests {
// A fresh clone has nothing to push.
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
// One local commit, ready to push, counted against the remote.
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
run(&["add", "-A"]);
run(&["commit", "-m", "local work"]);
@@ -837,17 +761,12 @@ mod tests {
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
// The local-only pass reads the tracking refs, no fetch needed:
// a commit lands locally long before the remote is reconciled.
let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status");
assert_eq!(local.ahead, 1);
// After the push the same commit is on the remote, idle again.
run(&["push", "origin", "main"]);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
// A commit made by someone else on the remote must not count as local work.
// It is behind, not ahead.
let remote_run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&remote)
-6
View File
@@ -8,11 +8,9 @@ use signed_git::{GitCache, Repo};
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
/// The global git repository mirror cache.
pub struct Mirrors;
impl Mirrors {
/// Install the global mirror cache root, once.
pub fn install(root: impl Into<PathBuf>) {
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
log::warn!("git cache root is already set, keeping the first one");
@@ -25,22 +23,18 @@ impl Mirrors {
.expect("git cache is initialized by signed_state::init")
}
/// The root directory of the repository mirrors.
pub(crate) fn root() -> PathBuf {
Self::cache().root().to_path_buf()
}
/// The on-disk path of the mirror of `addr`.
pub fn path(addr: &RepoAddr) -> PathBuf {
Self::cache().repo_path(addr)
}
/// Open the mirror of `addr`, if it has been cloned.
pub fn open(addr: &RepoAddr) -> Result<Option<Repo>> {
Self::cache().open(addr)
}
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repo> {
Self::cache().ensure_clone(addr, clone_urls)
}
+2 -8
View File
@@ -7,7 +7,6 @@ use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox
use crate::backend::Backend;
/// The user's persisted inbox read state.
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
@@ -15,7 +14,6 @@ pub struct Inbox {
}
impl Inbox {
/// The current read/archive cutoffs.
pub fn state(&self) -> &InboxReadState {
&self.state
}
@@ -67,7 +65,6 @@ impl Inbox {
cx.notify();
}
/// Sign the state with a random key and store it locally.
fn persist(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let (me, client) = {
@@ -91,7 +88,6 @@ impl Inbox {
task.detach();
}
/// The notifications and authored activity of `me`, grouped into inbox items.
pub async fn query(
client: &Client,
me: PublicKey,
@@ -127,7 +123,6 @@ impl Inbox {
}
impl Inbox {
/// `d` tag identifying the inbox state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
@@ -152,7 +147,8 @@ impl Inbox {
}
}
/// Sign with a random key and store locally.
// Signed with a random key: the state is local-only, authorship does not
// matter and no key material needs to be kept.
async fn save_state(
client: &Client,
me: PublicKey,
@@ -167,7 +163,6 @@ impl Inbox {
Ok(())
}
/// Notification events and a lookup of every ancestor they reference.
async fn fetch_notifications(
client: &Client,
me: PublicKey,
@@ -223,7 +218,6 @@ impl Inbox {
Ok((notifications, by_id))
}
/// Event ids referenced by `event` through its `e` and `E` tags.
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
event.tags.iter().filter_map(|tag| {
if tag.kind() != "e" && tag.kind() != "E" {
-11
View File
@@ -37,28 +37,17 @@ pub fn init(
// rustls uses the `aws_lc_rs` provider by default.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// Initialize the nostr client and signer
let backend = cx
.foreground_executor()
.block_on(async move { NostrBackend::open(db_path.as_ref()).await });
let backend = backend.expect("failed to initialize nostr backend");
let (client, signer) = (backend.client, backend.signer);
// Set Git cache for the repos root
Mirrors::install(repos_root);
// Set global stores for the backend
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
// Set global stores for the profile
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Set global stores for the repo list and local repos
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
// Set global stores for the local repos
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
// Set global stores for the checkouts
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
}
-11
View File
@@ -11,10 +11,8 @@ struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<LocalRepo>>,
pub scanning: bool,
scan_dirty: bool,
@@ -45,7 +43,6 @@ impl LocalReposStore {
}
}
/// Forget a repository that has just been published to NIP-34.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new(
self.repos
@@ -94,7 +91,6 @@ impl LocalReposStore {
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
@@ -106,8 +102,6 @@ impl LocalReposStore {
}
}
/// The NIP-34 coordinate a repository's detection resolved, when both the owner
/// and the identifier were recovered.
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
let binding = repo.nip34.as_ref()?;
let owner = binding.owner?;
@@ -116,18 +110,14 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
Some(RepoAddr::new(owner, identifier))
}
/// A scanned repository resolved against the known announcements.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedLocalRepo {
pub path: PathBuf,
/// `None` for a plain repository.
pub nip34: Option<Nip34Binding>,
/// The known announcement this repository is bound to, when one matched.
pub announcement: Option<Announcement>,
}
impl ResolvedLocalRepo {
/// The repository's directory name, or `Untitled` when the path has none.
pub fn name(&self) -> SharedString {
self.path
.file_name()
@@ -136,7 +126,6 @@ impl ResolvedLocalRepo {
}
}
/// Resolve the scanned repositories against the known announcements.
impl LocalReposStore {
pub fn resolve(
repos: &[LocalRepo],
+2 -16
View File
@@ -13,14 +13,10 @@ use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent};
use crate::bootstrap::sync_bootstrap_only;
/// How long to wait for more requests before firing a batched fetch.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Max authors per profile request, keeping each filter within relay limits.
const REQUEST_CHUNK: usize = 100;
/// Recent profiles prefetched at startup and read back from the cache.
const WARM_LIMIT: usize = 500;
/// A user profile as plain data for the UI, from the kind-0 metadata.
#[derive(Debug, Clone)]
pub struct Profile {
public_key: PublicKey,
@@ -43,7 +39,7 @@ impl Profile {
&self.metadata
}
/// Display name, falling back to `name`, then a shortened npub.
// Falls back to `name`, then a shortened npub.
pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata.display_name.as_ref()
&& !display_name.is_empty()
@@ -69,14 +65,9 @@ impl Profile {
}
}
/// Global profile cache.
///
/// Profiles are fetched in batches and kept as plain data.
pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>,
/// Public keys requested this session, main thread only.
seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>,
_subscription: Subscription,
}
@@ -126,9 +117,7 @@ impl ProfileStore {
}
}
/// Get a profile.
///
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
// Returns a placeholder until fetched; queues a fetch when uncached.
pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) {
return profile.clone();
@@ -179,7 +168,6 @@ impl ProfileStore {
.detach();
}
/// Re-read the latest metadata of `authors` from the local database in one query.
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
if authors.is_empty() {
return;
@@ -232,13 +220,11 @@ impl ProfileStore {
.detach();
}
/// Re-read the latest metadata of every requested author from the local database.
fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
self.apply_authors(authors, cx);
}
/// Fetch metadata for requested authors in batches, debounced to collect requests.
async fn handle_requests(
this: WeakEntity<ProfileStore>,
client: &Client,
+45 -90
View File
@@ -12,14 +12,11 @@ use signed_nostr::UniversalSigner;
pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3;
/// Pause before re-staging a state event after a transient denial.
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
/// Base URL of a grasp server, `https://<host>`.
///
/// `ws://` grasp servers use `http://<host>`, like ngit.
// `ws://` grasp servers, like ngit, use `http://<host>`; secure relays map to
// `https://<host>`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port.
let parsed = Url::parse(relay.as_str()).ok()?;
let host = parsed.host_str()?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
@@ -37,14 +34,12 @@ pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> O
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
/// GRASP-06 contributor namespace URL of a pull request tip.
// GRASP-06 contributor namespace URL of a pull request tip.
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
format!("{base_url}/prs/{npub}/{repo_id}.git")
}
/// Assemble the `clone` URLs of a pull request.
///
/// The author's GRASP-06 `/prs/` URLs come first.
// The author's GRASP-06 `/prs/` URLs come first.
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();
@@ -59,7 +54,6 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Ve
#[derive(Debug, Clone)]
pub struct GraspServer {
relay: RelayUrl,
/// `None` when the server accepted the data, the reason otherwise.
reason: Option<String>,
}
@@ -82,7 +76,6 @@ impl GraspServer {
&self.relay
}
/// `None` when the server accepted the data, the reason otherwise.
pub fn reason(&self) -> Option<&str> {
self.reason.as_deref()
}
@@ -90,11 +83,9 @@ impl GraspServer {
#[derive(Debug, Clone, Default)]
pub struct PushOutcome {
/// Per-server results, in the order the servers were listed.
pub servers: Vec<GraspServer>,
/// The newest state event a grasp relay accepted for this push, if any.
///
/// Broadcast to the other relays once a git server holds the data.
// The newest state event a grasp relay accepted for this push; broadcast
// to the other relays once a git server holds the data.
pub state_event: Option<Event>,
}
@@ -121,9 +112,7 @@ impl PushOutcome {
.join("; ")
}
/// A warning for a push only some grasp servers accepted.
///
/// `None` when every server accepted the push or nothing was pushed.
// `None` when every server accepted the push or nothing was pushed.
pub fn partial_warning(&self) -> Option<String> {
let accepted = self.accepted();
if self.servers.is_empty() || accepted == self.servers.len() {
@@ -137,25 +126,23 @@ impl PushOutcome {
}
}
/// Reasons a push attempt should be retried with a freshly staged state
/// event and a fresh git advertisement.
///
/// Two families are retried:
///
/// - **Purgatory denials**: the grasp server sends these when the state
/// event for the push has not reached its purgatory yet. Re-staging a
/// fresh event resolves them.
/// - **Stale advertisement races**: `git receive-pack` compares each ref
/// update against the value it advertised when the push started. The grasp
/// server's own background sync can move a ref in between - typically by
/// aligning the repository to a parked state event once the objects of an
/// earlier attempt land - so the compare-and-swap fails with `cannot lock
/// ref` / `incorrect old value provided`. A retry against the fresh
/// advertisement converges, and when the race is lost the pushed data is
/// usually already on the server (see `is_stale_advertisement_race` and
/// the convergence probe in `push_staged_to_grasps`).
///
/// Other rejections are not retried.
// Reasons a push attempt should be retried with a freshly staged state event
// and a fresh git advertisement. Two families are retried:
//
// - **Purgatory denials**: the grasp server sends these when the state event
// for the push has not reached its purgatory yet. Re-staging a fresh event
// resolves them.
// - **Stale advertisement races**: `git receive-pack` compares each ref
// update against the value it advertised when the push started. The grasp
// server's own background sync can move a ref in between - typically by
// aligning the repository to a parked state event once the objects of an
// earlier attempt land - so the compare-and-swap fails with `cannot lock
// ref` / `incorrect old value provided`. A retry against the fresh
// advertisement converges, and when the race is lost the pushed data is
// usually already on the server (see `is_stale_advertisement_race` and
// the convergence probe in `push_staged_to_grasps`).
//
// Other rejections are not retried.
fn is_transient_grasp_denial(stderr: &str) -> bool {
let error = stderr.to_lowercase();
[
@@ -171,19 +158,17 @@ fn is_transient_grasp_denial(stderr: &str) -> bool {
.any(|marker| error.contains(marker))
}
/// A push rejected because `git receive-pack`'s compare-and-swap lost to the
/// grasp server's own background ref alignment: the ref moved between this
/// push's advertisement and its ref transaction (`cannot lock ref ... is at
/// ... but expected ...` / `incorrect old value provided`). The pushed data
/// is usually already on the server by then.
// A push rejected because `git receive-pack`'s compare-and-swap lost to the
// grasp server's own background ref alignment: the ref moved between this
// push's advertisement and its ref transaction (`cannot lock ref ... is at
// ... but expected ...` / `incorrect old value provided`). The pushed data
// is usually already on the server by then.
fn is_stale_advertisement_race(stderr: &str) -> bool {
let error = stderr.to_lowercase();
error.contains("cannot lock ref") || error.contains("incorrect old value provided")
}
/// Keep `event` as the push's fan-out state event when it is newer than the
/// current one. All staged events carry the same refs; the newest timestamp
/// wins on the relays.
// All staged events carry the same refs; the newest timestamp wins on the relays.
fn keep_newest(state_event: &mut Option<Event>, event: Event) {
if state_event
.as_ref()
@@ -193,8 +178,8 @@ fn keep_newest(state_event: &mut Option<Event>, event: Event) {
}
}
/// The grasp push pipeline: stage a signed state event on each server's
/// relay, then push the git data, retrying transient denials.
// The grasp push pipeline: stage a signed state event on each server's
// relay, then push the git data, retrying transient denials.
#[derive(Clone)]
pub(crate) struct GraspPush {
client: Client,
@@ -206,7 +191,6 @@ impl GraspPush {
Self { client, signer }
}
/// The event was accepted by at least one relay, or a descriptive error otherwise.
pub(crate) fn require_relay_accepted(
output: SendEventOutput,
event: Event,
@@ -224,10 +208,7 @@ impl GraspPush {
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.
// A relay hiccup should not block sign-up, so failures are only logged.
pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) {
let result: Result<(), Error> = async {
let event = builder.finalize_async(&self.signer).await?;
@@ -242,21 +223,16 @@ impl GraspPush {
}
}
/// Sign `builder`, broadcast the event and require a relay to accept it.
/// Returns the signed event.
pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result<Event, Error> {
let event = builder.finalize_async(&self.signer).await?;
self.send_accepted(event).await
}
/// Broadcast an already signed event and require a relay to accept it.
/// Returns the event.
pub(crate) async fn send_accepted(&self, event: Event) -> Result<Event, Error> {
let output = self.client.send_event(&event).broadcast().await?;
Self::require_relay_accepted(output, event)
}
/// Sign and send a single NIP-09 deletion request for `event`.
pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> {
let builder = EventDeletionRequest::new()
.id(event.id)
@@ -268,12 +244,9 @@ impl GraspPush {
Ok(())
}
/// Sign a fresh kind `30618` state event for the push.
///
/// `last_created_at` is the timestamp of the previous event signed for this push.
/// Retries within the same second get the next second: a grasp relay
/// treats a same-id resend as a duplicate and does not re-run its ingest,
/// so an identical resend cannot re-park a state event lost from its purgatory.
// Retries within the same second get the next second: a grasp relay
// treats a same-id resend as a duplicate and does not re-run its ingest,
// so an identical resend cannot re-park a state event lost from its purgatory.
async fn sign_state_event(
&self,
repo_id: &str,
@@ -297,11 +270,8 @@ impl GraspPush {
Ok((event, created_at))
}
/// Ensure the relay is known and connected, then publish `event` to it.
///
/// `Ok` only when the relay confirmed the event.
/// On a grasp relay the accept parks the event in purgatory,
/// which authorizes the paired git push.
// `Ok` only when the relay confirmed the event. On a grasp relay the
// accept parks the event in purgatory, which authorizes the paired git push.
async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> {
self.client
.add_relay(relay)
@@ -353,13 +323,12 @@ impl GraspPush {
let mut reason = None;
let mut last_created_at = 0;
// The last state event staged on this server, for the convergence
// probe below when every push attempt lost the stale-ref race.
// For the convergence probe when every push attempt lost the
// stale-ref race.
let mut staged_event = None;
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
if attempt > 1 {
// Give the server's ingest a moment before re-staging.
executor.timer(GRASP_RETRY_DELAY).await;
}
@@ -376,14 +345,12 @@ impl GraspPush {
last_created_at = created_at;
// Stage the state event on this server's own relay.
// A failed stage means the grasp never parked the state,
// so the git push would be denied anyway: skip it (the eligibility gate).
// A failed stage means the grasp never parked the state, so
// the git push would be denied anyway: skip it.
if let Err(e) = self.stage_event_on_relay(relay, &event).await {
// One retry absorbs a relay connect blip, on the first
// attempt only.
if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() {
// staged on the retry
} else {
reason = Some(e);
break 'server;
@@ -410,10 +377,9 @@ impl GraspPush {
// The grasp's own background sync aligns refs to staged state
// events as soon as the objects land, which can beat every push
// attempt's compare-and-swap (`cannot lock ref ... but expected`).
// When the last denial was that race the sync has usually finished
// by now: verify the advertised refs and accept the server when the
// pushed data is already there.
// attempt's compare-and-swap. When the last denial was that race
// the sync has usually finished by now: verify the advertised refs
// and accept the server when the pushed data is already there.
if let Some(last_reason) = &reason
&& is_stale_advertisement_race(last_reason)
&& Repo::open(path)
@@ -476,7 +442,6 @@ mod tests {
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"
@@ -508,14 +473,11 @@ mod tests {
#[test]
fn transient_grasp_denials_are_classified() {
// The exact server rejection that started this work: the state event
// had not reached the grasp's purgatory before the git push.
let reported = "remote: ERR authorisation failed: No state events in purgatory\n\
fatal: the remote end hung up unexpectedly\n\
error: failed to push some refs to 'https://relay.ngit.dev/...git'";
assert!(is_transient_grasp_denial(reported));
// The other purgatory states a fresh event resolves.
assert!(is_transient_grasp_denial(
"remote: ERR authorisation failed: No matching state event found in purgatory"
));
@@ -531,7 +493,6 @@ mod tests {
"remote: ERR authorisation failed: No repository announcement found"
));
// Rejections a fresh state event cannot fix are not retried.
assert!(!is_transient_grasp_denial(
"remote: ERR authorisation failed: not a maintainer of this repository"
));
@@ -546,9 +507,6 @@ mod tests {
#[test]
fn stale_ref_races_are_retried() {
// The grasp's background sync aligned the ref to a parked state event
// between this push's advertisement and its ref transaction. The ref
// is usually already where the push wants it, so a retry converges.
let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \
cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \
bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\
@@ -558,7 +516,6 @@ mod tests {
assert!(is_transient_grasp_denial(reported));
assert!(is_stale_advertisement_race(reported));
// Markers match independently of the surrounding git output.
assert!(is_stale_advertisement_race(
"cannot lock ref 'refs/heads/main'"
));
@@ -566,10 +523,8 @@ mod tests {
"! [remote rejected] main -> main (incorrect old value provided)"
));
// A purgatory denial is not a stale-advertisement race.
assert!(!is_stale_advertisement_race("No state events in purgatory"));
// A real divergence is a different error and stays permanent.
assert!(!is_transient_grasp_denial(
" ! [rejected] main -> main (non-fast-forward)"
));
+1 -5
View File
@@ -1,4 +1,3 @@
/// Refresh coalescing shared by the event stores.
#[derive(Debug, Default)]
pub struct RefreshGate {
running: bool,
@@ -7,9 +6,7 @@ pub struct RefreshGate {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run covers the request, start one now.
Schedule,
/// A run is in flight and covers the request, fold it into a follow-up.
Fold,
}
@@ -31,13 +28,12 @@ impl RefreshGate {
self.running = true;
}
/// The run ended. Whether a request arrived while it ran.
pub fn finish(&mut self) -> bool {
self.running = false;
std::mem::take(&mut self.dirty)
}
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
// Pending follow-up requests survive an abandoned run.
pub fn abort(&mut self) {
self.running = false;
}
+35 -147
View File
@@ -21,74 +21,41 @@ use crate::checkouts::CheckoutsStore;
use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls};
use crate::repos::RepoListStore;
/// Maximum size of one patch event.
///
/// NIP-34 suggests patches when each event is under 60kb.
// NIP-34 suggests patches when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Per-repository store.
///
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
pub struct RepoStore {
/// NIP-34 address. `None` while the repository is local-only.
addr: Option<RepoAddr>,
/// Latest announcement. Seeded from the open-time hint, replaced by the
/// database's latest on the first pass. `None` while local-only.
// Seeded from the open-time hint, replaced by the database's latest on the
// first pass. `None` while local-only.
pub announcement: Option<Announcement>,
/// Local working copy. The scan path for a local repository, kept when it is
/// later announced so the panel keeps its worktree.
// The scan path for a local repository, kept when it is later announced so
// the panel keeps its worktree.
pub path: Option<PathBuf>,
/// NIP-34 state detected on disk for a local repository, if any.
pub nip34: Option<Nip34Binding>,
/// The first local pass has been applied.
///
/// Views distinguish "no data yet" from a genuinely empty repository with it.
// Views distinguish "no data yet" from a genuinely empty repository with it.
pub loaded: bool,
/// Branch pointed to by `HEAD` in the latest state announcement.
pub head: Option<String>,
pub issues: Vec<Event>,
pub patches: Vec<Event>,
pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>,
/// Resolved status per root event, issue, patch or PR.
status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue and root PR counts.
/// Computed with [`Self::status_by_root`] on every refresh.
open_issue_count: usize,
open_pr_count: usize,
pub last_error: Option<String>,
/// Non-fatal warning of the last action, if any.
///
/// Example, a PR published without its commit reaching a grasp server.
pub last_warning: Option<String>,
/// Warning of the last push that only some grasp servers accepted.
///
/// The repository is out of sync on the rejected servers until it is republished.
// The repository is out of sync on the rejected servers until republished.
pub last_push_warning: Option<String>,
/// A republish or a checkout push is in flight.
///
/// Views show a spinner and disable their push triggers while it is set.
pub pushing: bool,
/// A clone-into-a-folder operation is in flight.
///
/// Views show a spinner and disable the clone trigger while it is set.
pub cloning: bool,
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
///
/// Avoids re-subscribing and re-fetching on every refresh.
// Avoids re-subscribing and re-fetching on every refresh.
repo_relays: HashSet<RelayUrl>,
/// Root events, issues, patches and PRs, already fetched per root.
///
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
// Covers NIP-22 comments and statuses without an `a` tag.
root_fetches: HashSet<EventId>,
/// Maintainers already synced through gossip in Uncensored mode.
///
/// Avoids re-running the maintainer Auto sync on every refresh.
synced_maintainers: HashSet<PublicKey>,
/// In-flight tasks, cancelled when the store drops.
// In-flight tasks, cancelled when the store drops.
tasks: Vec<Task<Result<(), Error>>>,
/// Backend subscription of an announced repository. `None` while local-only.
_subscription: Option<Subscription>,
}
@@ -141,7 +108,6 @@ impl RepoStore {
}
}
/// Local repository discovered by the scan, not announced to NIP-34 yet.
pub fn new_local(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
Self {
addr: None,
@@ -170,7 +136,6 @@ impl RepoStore {
}
}
/// An announced repository whose working copy is already on disk.
pub fn from_worktree(
addr: RepoAddr,
announcement: Announcement,
@@ -182,7 +147,6 @@ impl RepoStore {
store
}
/// Switch a local repository to its NIP-34 mode, keeping its path.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
self.addr = Some(announcement.addr());
self.announcement = Some(announcement.clone());
@@ -235,7 +199,6 @@ impl RepoStore {
self.addr.as_ref()
}
/// Returns the repository's name, or `Unknown` when not known.
pub fn name(&self) -> SharedString {
self.announcement
.as_ref()
@@ -244,9 +207,6 @@ impl RepoStore {
})
}
/// Filters that make up a repository.
///
/// Announcement, state, activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
Filter::new()
@@ -255,12 +215,11 @@ impl RepoStore {
.identifier(addr.identifier()),
addr.activity_filter(),
];
// Deletion requests, NIP-09/62, must be known before any event is shown.
// Deletion requests must be known before any event is shown.
filters.extend(addr.deletion_filters());
filters
}
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
@@ -285,39 +244,33 @@ impl RepoStore {
});
}
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
// NIP-34 events tag the announcement author, which may not be a
// maintainer for subordinate forks.
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
let mut pubkeys = maintainers.to_vec();
// NIP-34 events tag the announcement author,
// which may not be a maintainer for subordinate forks.
if !pubkeys.contains(&addr.public_key()) {
pubkeys.push(addr.public_key());
}
vec![
// Announcement and state events, including co-maintainer states.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.authors(pubkeys.clone())
.identifier(addr.identifier()),
// Activity tagging a maintainer, resolved to their read relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr.coordinate())
.pubkeys(pubkeys.clone()),
// Activity authored by a maintainer, resolved to their write relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr.coordinate())
.authors(pubkeys.clone()),
// Deletions authored by a maintainer.
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.authors(pubkeys),
]
}
/// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
let strategy = SettingsStore::try_global(cx)
.map(|store| store.read(cx).settings().event_fetching)
@@ -358,7 +311,6 @@ impl RepoStore {
});
}
/// Re-query the local database and update all fields.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
@@ -531,7 +483,6 @@ impl RepoStore {
this.announcement = announcement;
}
// The announcement may list relays for this repository's activity.
let relays = this
.announcement
.as_ref()
@@ -540,7 +491,6 @@ impl RepoStore {
this.connect_announced_relays(&relays, cx);
// Uncensored mode also covers the maintainers' NIP-65 relays.
let maintainers = this
.announcement
.as_ref()
@@ -602,22 +552,18 @@ impl RepoStore {
self.tasks.push(task);
}
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
pub fn status_of(&self, root: &Event) -> RepoStatus {
status_of(&self.status_by_root, root)
}
/// Number of open issues.
pub fn issue_count(&self) -> usize {
self.open_issue_count
}
/// Number of open pull requests.
pub fn pull_request_count(&self) -> usize {
self.open_pr_count
}
/// Whether `user` is the author or owner of this repository.
pub fn is_author(&self, user: &PublicKey) -> bool {
self.addr
.as_ref()
@@ -647,14 +593,10 @@ impl RepoStore {
.filter(move |e| e.references_root(root))
}
/// Comment on a root event, an issue or PR, per NIP-34, kind 1111.
pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) {
self.reply(root, None, content, cx);
}
/// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment.
///
/// `None` publishes a top-level comment on the root itself.
fn reply(
&mut self,
root: &Event,
@@ -710,8 +652,8 @@ impl RepoStore {
return;
}
// The tip of the series is its last commit.
// `git format-patch` orders patches oldest first.
// The tip of the series is its last commit; `git format-patch` orders
// patches oldest first.
let Some(current_commit) = series.tip_commit() else {
self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
@@ -731,7 +673,6 @@ impl RepoStore {
cx.notify();
return;
};
// The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap();
let owner = addr.public_key();
@@ -762,8 +703,6 @@ impl RepoStore {
};
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
// The PR references the root patch,
// viewers can then find the patch without carrying it inline.
let root_patch = match publish_patch_series(
&client,
&signer,
@@ -785,11 +724,10 @@ impl RepoStore {
}
};
// GRASP-06 pushes the tip to the author's own grasp servers.
// The path is `/prs/<author-npub>/<repo-id>.git`.
// Contributing to another project never depends on that project's servers.
// Resolve the servers from the author's latest kind-10317 grasp list.
// The settings defaults stand in when no list is published or the query fails.
// GRASP-06 pushes the tip to the author's own grasp servers at
// `/prs/<author-npub>/<repo-id>.git`; contributing to another
// project never depends on that project's servers. Resolve the
// servers from the author's latest kind-10317 grasp list.
let author_servers = {
let query_client = client.clone();
let published = cx
@@ -866,7 +804,6 @@ impl RepoStore {
}
})?;
// Sign before publishing.
let event = cx
.background_spawn({
let signer = signer.clone();
@@ -883,7 +820,6 @@ impl RepoStore {
let path = path.clone();
let tip = tip.clone();
let reference = reference.clone();
// Author servers first, then the announced base grasp servers.
let targets: Vec<(String, String)> = author_targets
.into_iter()
.chain(base_targets)
@@ -933,8 +869,6 @@ impl RepoStore {
}
};
// A draft PR carries a kind-1633 status event, NIP-34.
// Publish it right after the PR event so viewers never show it open.
if draft {
this.update(cx, |this, cx| {
this.set_status(&pr_event, RepoStatus::Draft, cx);
@@ -946,12 +880,6 @@ impl RepoStore {
self.tasks.push(task);
}
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
/// then open a pull request from it.
///
/// Fails descriptively when there are no commits to propose or the patch
/// could not be generated; otherwise publishes exactly like
/// [`Self::open_pull_request`].
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request_from_refs(
&mut self,
@@ -965,8 +893,8 @@ impl RepoStore {
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.
// Regenerate at submit time so the published patch covers the
// current tip of the compare branch.
let patch = cx
.background_spawn({
let repo_path = repo_path.clone();
@@ -999,9 +927,6 @@ impl RepoStore {
})
}
/// Update a pull request.
///
/// Other authors must open a new PR.
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
@@ -1034,7 +959,7 @@ impl RepoStore {
return;
}
// The new tip of the PR is the last commit of the series.
// The tip of the updated PR is the last commit of the series.
let Some(current_commit) = series.tip_commit() else {
self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
@@ -1043,8 +968,8 @@ impl RepoStore {
return;
};
// The first revision patch replies to the original root patch, NIP-34.
// Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
// The first revision patch replies to the original root patch, NIP-34:
// use the PR's `e` tag, or the oldest patch of the linked set.
let root_patch_id = root.tags.event_ids().next().or_else(|| {
PullRequest::new(root)
.patches(self.patches.iter())
@@ -1097,8 +1022,8 @@ impl RepoStore {
}
.into_event_builder();
// The `r` EUC tag lets clients subscribe to all PR updates.
// The SDK builder omits it.
// The `r` EUC tag lets clients subscribe to all PR updates; the
// SDK builder omits it.
match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder,
@@ -1122,9 +1047,6 @@ impl RepoStore {
self.tasks.push(task);
}
/// Set the status of a root event.
///
/// Only the root author or a maintainer may set it, per NIP-34.
fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None;
@@ -1166,8 +1088,6 @@ impl RepoStore {
self.publish(builder, cx);
}
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
let addr = self.addr.as_ref()?;
self.announcement.clone().or_else(|| {
@@ -1227,11 +1147,6 @@ impl RepoStore {
self.run_push(push, Some((addr, path)), cx)
}
/// Run a backend push task, tracking progress in [`Self::pushing`] and
/// the outcome in [`Self::last_error`] and [`Self::last_push_warning`].
///
/// `pushed_checkout` names the checkout whose ready-to-push statuses
/// should be recomputed after the remote moved.
fn run_push(
&mut self,
push: Task<Result<PushOutcome, Error>>,
@@ -1252,11 +1167,8 @@ impl RepoStore {
match &result {
Ok(outcome) => {
this.last_error = None;
// A push only some grasp servers accepted is a warning:
// the repo is out of sync on the rest until it is republished.
this.last_push_warning = outcome.partial_warning();
if let Some((addr, path)) = &pushed_checkout {
// The remote moved, so recompute the ready-to-push statuses.
CheckoutsStore::global(cx).update(cx, |store, cx| {
store.checkout_pushed(addr, path, cx);
});
@@ -1275,10 +1187,6 @@ impl RepoStore {
})
}
/// Delete the repository from nostr, announcement, state and activity.
///
/// Only the repository owner may delete it. The lists update when the
/// deletion events arrive.
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
@@ -1300,8 +1208,7 @@ impl RepoStore {
})
}
/// Clone the repository into `destination`, a user-chosen folder outside
/// the cache, and remember the clone as a checkout of this repository.
// A user-chosen folder outside the cache; remembered as a checkout.
pub fn clone_to_folder(
&mut self,
destination: PathBuf,
@@ -1329,8 +1236,7 @@ impl RepoStore {
let clone = {
let destination = destination.clone();
// The clone's repository handle is dropped in the task: gix handles
// are not `Send`, they must not cross the spawn boundary.
// gix handles are not `Send` and must not cross the spawn boundary.
cx.background_spawn(async move { Repo::clone(&clone_urls, &destination).map(|_| ()) })
};
@@ -1376,12 +1282,8 @@ impl RepoStore {
Task::ready(Err(anyhow::anyhow!("{message}")))
}
/// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`].
///
/// Every one-shot repository event (issue, comment, status) goes through
/// this. Multi-step flows (opening or updating a pull request, a patch
/// series) call the SDK directly instead, since their error handling and
/// post-conditions differ per step.
// Every one-shot repository event (issue, comment, status) goes through
// this; multi-step flows call the SDK directly instead.
fn publish(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None;
@@ -1446,15 +1348,13 @@ fn resolve_statuses(
.collect()
}
/// The proposed commit of a `git format-patch` output.
/// It is the `From <commit>` header on the first line.
// The `From <commit>` header on the first line.
fn patch_current_commit(patch: &str) -> Option<&str> {
let line = patch.lines().next()?;
let hex = line.strip_prefix("From ")?;
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
}
/// A `git format-patch` series with the facts derived from its parts.
struct PatchSeries {
parts: Vec<String>,
}
@@ -1469,7 +1369,7 @@ impl PatchSeries {
}
}
/// The byte length of the first part over the NIP-34 size suggestion.
// The byte length of the first part over the NIP-34 size suggestion.
fn oversized_length(&self) -> Option<usize> {
self.parts
.iter()
@@ -1477,8 +1377,8 @@ impl PatchSeries {
.find(|length| *length > MAX_PATCH_EVENT_BYTES)
}
/// The tip of the series is its last commit;
/// `git format-patch` orders patches oldest first.
// The tip of the series is its last commit; `git format-patch` orders
// patches oldest first.
fn tip_commit(&self) -> Option<Sha1Hash> {
self.parts
.last()
@@ -1494,9 +1394,7 @@ impl PatchSeries {
}
}
/// Publish a `git format-patch` series as chained kind-1617 events.
///
/// Returns the root event, the one a PR references.
// Returns the root event, the one a PR references.
#[allow(clippy::too_many_arguments)]
async fn publish_patch_series(
client: &Client,
@@ -1566,7 +1464,6 @@ async fn publish_patch_series(
root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
}
/// Build a NIP-22 kind-1111 comment.
fn comment_builder(
root: &Event,
parent: Option<&Event>,
@@ -1630,19 +1527,15 @@ mod tests {
assert!(kinds.contains(&expected), "missing {expected} tag");
}
// The uppercase `E` tag scopes the root, with its id, relay hint and author.
let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
let slice = e.as_slice();
assert_eq!(slice[1], root.id.to_hex());
assert_eq!(slice[2], relay.as_str());
assert_eq!(slice[3], root.pubkey.to_hex());
// The lowercase `e` tag references the parent.
// For a top-level comment the parent is the root itself.
let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
assert_eq!(e.as_slice()[1], root.id.to_hex());
// Signed's own `references_root` must keep matching the comment.
assert!(event.references_root(&root.id));
}
@@ -1652,13 +1545,11 @@ mod tests {
let maintainer = Keys::generate().public_key();
let addr = signed_core::RepoAddr::new(owner, "my-repo");
// The owner is not among the maintainers, as on a subordinate fork.
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
assert_eq!(filters.len(), 4);
let expected = HashSet::from([owner, maintainer]);
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
let named = |filter: &Filter| -> HashSet<PublicKey> {
let authors = filter.authors.iter().flatten().copied();
let p_tag = filter
@@ -1670,7 +1561,6 @@ mod tests {
authors.chain(p_tag).collect()
};
// Announcement and state events, scoped to the repository identifier.
let announcement = &filters[0];
assert_eq!(named(announcement), expected);
assert!(
@@ -1679,7 +1569,6 @@ mod tests {
.contains_key(&SingleLetterTag::LOWERCASE_D)
);
// Activity filters, scoped to the repository coordinate.
for filter in &filters[1..3] {
assert_eq!(named(filter), expected);
assert!(
@@ -1689,7 +1578,6 @@ mod tests {
);
}
// Deletions, named by author only.
let deletions = &filters[3];
assert_eq!(
deletions
+10 -28
View File
@@ -17,37 +17,27 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
impl Global for GlobalRepoListStore {}
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository.
pub issues: u32,
/// Root `3063` pull request events addressed to the repository.
///
/// PR updates are not new PRs and do not count.
// PR updates are not new PRs and do not count.
pub pull_requests: u32,
/// `1617` patch events addressed to the repository.
pub commits: u32,
}
impl RepoActivityCounts {
/// Total issues, pull requests and commits, the popularity ranking key.
// The popularity ranking key.
pub fn score(self) -> u32 {
self.issues + self.pull_requests + self.commits
}
}
/// Store listing the discovered repository announcements, newest first.
pub struct RepoListStore {
/// Shared so views can clone the list per frame without a deep copy.
// Shared so views can clone the list per frame without a deep copy.
pub announcements: Arc<Vec<Announcement>>,
/// Latest known activity timestamp per repository.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues, pull requests and commits per repository.
///
/// Used for the Popular ranking of the explore list.
// For the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Own repositories whose state events were fetched from their announced relays.
state_synced_repos: HashSet<RepoAddr>,
refresh: RefreshGate,
_subscription: Subscription,
@@ -100,7 +90,6 @@ impl RepoListStore {
}
}
/// The announcements of `user`, newest first.
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
self.announcements
.iter()
@@ -125,7 +114,6 @@ impl RepoListStore {
});
}
/// Fetch the state events of the user's own repositories.
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
@@ -149,10 +137,8 @@ impl RepoListStore {
}
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
// Runs immediately: the backend pump already batches the relay events
// that trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
return;
@@ -174,8 +160,8 @@ impl RepoListStore {
let deletion_events = client.database().query(Filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
// Dedup and sort off the main thread.
// Only the final list crosses back into the entity.
// Dedup and sort off the main thread; only the final list crosses
// back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events {
@@ -220,7 +206,6 @@ impl RepoListStore {
*entry = (*entry).max(event.created_at);
}
// Bound the activity query to a recent window.
// Older repos fall back to their announcement or state timestamps.
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
@@ -242,8 +227,8 @@ impl RepoListStore {
}
}
// Popularity counts per repository, issues, pull requests and patches.
// Unbounded, unlike the windowed activity query above, so totals are exact.
// Unbounded, unlike the windowed activity query above, so totals
// are exact.
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
@@ -274,7 +259,6 @@ impl RepoListStore {
cx.spawn(async move |this, cx| {
let (announcements, last_activity, counts) = match work.await {
Ok(results) => results,
// Database errors are transient, keep the last list.
Err(_) => {
return this.update(cx, |this, _cx| {
this.refresh.abort();
@@ -292,8 +276,6 @@ impl RepoListStore {
this.refresh.finish()
})?;
// Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}