From 4b4b8b5cf26a4b4a5515ffb0b73440ae2b75f540 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 16 Oct 2026 20:07:13 +0700 Subject: [PATCH] chore: and reconcile pushes with server only refs --- CHANGELOG.md | 3 + crates/signed_git/src/repo.rs | 36 +++- crates/signed_git/src/tests.rs | 160 +++++++++++++++++ crates/signed_state/src/backend.rs | 29 ++- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/repo.rs | 18 ++ crates/signed_state/src/state.rs | 238 +++++++++++++++++-------- crates/workspace/src/views/repo/mod.rs | 142 ++++++++++++++- 8 files changed, 546 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc8023..d23cde3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Allow choosing the default branch when publishing a repository - Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view - Add a step-by-step push checklist while publishing and syncing a repository +- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have ### Changed @@ -35,6 +36,8 @@ - Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone - Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned - Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed +- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks +- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs ### Removed diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 811f840..fd26bd7 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -1222,22 +1222,25 @@ impl Repo { base_url, owner, repo_id, - &["refs/heads/main:refs/heads/main"], + &["+refs/heads/main:refs/heads/main"], ) } /// Mirrors all branches and tags to the grasp repository at `base_url`. + /// + /// Force refspecs keep the server ref map following the local repository, + /// grasp authorizes pushes by ref map equality, not fast-forwards. pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { self.push_refspecs( base_url, owner, repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + &["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"], ) } /// Pushes explicit refspecs to the grasp repository at `base_url`. - fn push_refspecs( + pub fn push_refspecs( &self, base_url: &str, owner: &str, @@ -1262,6 +1265,33 @@ impl Repo { Ok(()) } + /// Lists branches and tags advertised by the remote at `url`. + pub fn ls_remote(&self, url: &str) -> Result> { + let url = Self::transport_url(url); + let output = Self::run_git(self.workdir_or_dot(), &["ls-remote", &url], "git ls-remote")?; + + if !output.status.success() { + bail!( + "git ls-remote on {url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let refs = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let (oid, name) = line.split_once('\t')?; + let name = name.trim(); + + (name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) + .then(|| (name.to_owned(), oid.to_owned())) + .filter(|(name, _)| !name.ends_with("^{}")) + }) + .collect(); + + Ok(refs) + } + /// Sets the `origin` URL when the repository has no remote yet. pub fn ensure_origin(&self, url: &str) -> Result<()> { if self.inner.find_remote("origin").is_ok() { diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 0805cdf..f55665f 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -122,6 +122,166 @@ fn push_all_mirrors_branches_and_tags() { assert!(refs.contains("refs/tags/v1.0")); } +#[test] +fn ls_remote_lists_branches_and_tags_only() { + let server = tempfile::tempdir().unwrap(); + let server_repo = bare_server(server.path(), "npub1test", "my-repo"); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["branch", "feature"]); + git_run(dir, &["tag", "v1"]); + git_run(dir, &["tag", "-a", "v2", "-m", "annotated"]); + + let base_url = format!("file://{}", server.path().display()); + let local = Repo::open(dir).expect("open"); + local + .push_all(&base_url, "npub1test", "my-repo") + .expect("push"); + + // Refs outside heads and tags, and the HEAD symref line, are not listed. + let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); + git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]); + + let remote = local + .ls_remote(&format!("file://{}", server_repo.display())) + .expect("ls-remote"); + let names: Vec = remote.iter().map(|(name, _)| name.clone()).collect(); + + assert_eq!( + names, + vec![ + "refs/heads/feature".to_owned(), + "refs/heads/main".to_owned(), + "refs/tags/v1".to_owned(), + "refs/tags/v2".to_owned(), + ] + ); + assert_eq!( + remote + .iter() + .find(|(name, _)| name == "refs/heads/main") + .map(|(_, oid)| oid.as_str()), + Some(main.as_str()) + ); + // The annotated tag lists the tag object, matching the local ref state, + // without a peeled `^{}` duplicate. + let v2 = git_in(dir, &["rev-parse", "refs/tags/v2"]).expect("v2 oid"); + assert_eq!( + remote + .iter() + .find(|(name, _)| name == "refs/tags/v2") + .map(|(_, oid)| oid.as_str()), + Some(v2.as_str()) + ); +} + +#[test] +fn push_all_force_updates_diverged_remote_branches() { + let server = tempfile::tempdir().unwrap(); + let server_repo = bare_server(server.path(), "npub1test", "my-repo"); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + std::fs::write(dir.path().join("b.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + let dir = dir.path(); + + let base_url = format!("file://{}", server.path().display()); + let local = Repo::open(dir).expect("open"); + local + .push_all(&base_url, "npub1test", "my-repo") + .expect("initial push"); + + // Diverge local main from the pushed state. + git_run(dir, &["reset", "-q", "--hard", "HEAD~1"]); + std::fs::write(dir.join("c.txt"), b"three").expect("write"); + commit_all(&repo, "diverged"); + + local + .push_all(&base_url, "npub1test", "my-repo") + .expect("force push"); + + assert_eq!( + git_in(dir, &["rev-parse", "refs/heads/main"]).expect("local main"), + git_in(&server_repo, &["rev-parse", "refs/heads/main"]).expect("server main") + ); +} + +#[test] +fn push_refspecs_deletes_server_refs_missing_locally() { + let server = tempfile::tempdir().unwrap(); + let server_repo = bare_server(server.path(), "npub1test", "my-repo"); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + + git_run(dir, &["branch", "feature"]); + git_run(dir, &["tag", "v1"]); + + let base_url = format!("file://{}", server.path().display()); + let local = Repo::open(dir).expect("open"); + local + .push_all(&base_url, "npub1test", "my-repo") + .expect("initial push"); + + git_run(dir, &["branch", "-D", "feature"]); + git_run(dir, &["tag", "-d", "v1"]); + + local + .push_refspecs( + &base_url, + "npub1test", + "my-repo", + &[ + "+refs/heads/*:refs/heads/*", + "+refs/tags/*:refs/tags/*", + ":refs/heads/feature", + ":refs/tags/v1", + ], + ) + .expect("push with deletions"); + + let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); + assert!(refs.contains("refs/heads/main")); + assert!(!refs.contains("refs/heads/feature")); + assert!(!refs.contains("refs/tags/v1")); +} + +#[test] +fn fetch_refs_brings_server_only_branches_into_local() { + let server = tempfile::tempdir().unwrap(); + bare_server(server.path(), "npub1test", "my-repo"); + + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = dir.path(); + git_run(dir, &["branch", "feature"]); + + let base_url = format!("file://{}", server.path().display()); + Repo::open(dir) + .expect("open") + .push_all(&base_url, "npub1test", "my-repo") + .expect("push"); + let feature = git_in(dir, &["rev-parse", "refs/heads/feature"]).expect("feature oid"); + + // A repository that never had the branch fetches it by name. + let (other_dir, _other_repo) = fixture(&[("unrelated.txt", b"x")]); + let other = Repo::open(other_dir.path()).expect("open other"); + let server_url = format!("file://{}/npub1test/my-repo.git", server.path().display()); + other + .fetch_refs(&[&server_url], "+refs/heads/feature:refs/heads/feature") + .expect("fetch ref"); + + assert_eq!( + git_in(other_dir.path(), &["rev-parse", "refs/heads/feature"]).expect("fetched feature"), + feature + ); +} + #[test] fn ref_state_matches_the_git_cli_ref_listing() { let (dir, repo) = fixture(&[("a.txt", b"one")]); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 62dc33b..e8f4016 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -21,7 +21,7 @@ use crate::git_store::Mirrors; use crate::inbox::Inbox; use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url}; use crate::repos::RepoListStore; -use crate::state::{GitPushMode, GitRef, PushStepStatus, SyncState}; +use crate::state::{GitPushMode, GitPushPolicy, GitRef, PushStepStatus, SyncState}; pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -588,6 +588,7 @@ impl Backend { head: Some("main".to_owned()), path: Some(destination.clone()), mode: GitPushMode::Main, + policy: GitPushPolicy::default(), }; let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { @@ -735,7 +736,17 @@ impl Backend { cx: &mut Context, ) -> Task> { let path = Mirrors::path(&announcement.addr()); - self.push_repo_from(announcement, path, None, cx) + self.push_repo_from(announcement, path, None, GitPushPolicy::Reconcile, cx) + } + + /// Republishes the mirror and deletes grasp refs the mirror does not have. + pub fn reset_repository( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) -> Task> { + let path = Mirrors::path(&announcement.addr()); + self.push_repo_from(announcement, path, None, GitPushPolicy::ResetRemote, cx) } pub fn push_checkout( @@ -745,7 +756,13 @@ impl Backend { announced_head: Option, cx: &mut Context, ) -> Task> { - self.push_repo_from(announcement, checkout, announced_head, cx) + self.push_repo_from( + announcement, + checkout, + announced_head, + GitPushPolicy::Reconcile, + cx, + ) } fn push_repo_from( @@ -753,6 +770,7 @@ impl Backend { announcement: Announcement, path: PathBuf, announced_head: Option, + policy: GitPushPolicy, cx: &mut Context, ) -> Task> { let addr = announcement.addr(); @@ -789,6 +807,11 @@ impl Backend { }); work.await? }; + state.policy = policy; + + // Fetch grasp refs the local repository lacks before the state event is signed, + // so the servers' full ref map is declared. + state.reconcile(&announcement, cx).await?; // Keep the announced default branch in `HEAD` when it is among the pushed refs. if let Some(head) = announced_head diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index c757f22..cd82d51 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -28,7 +28,7 @@ pub use repo::RepoStore; pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; -pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; +pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState}; pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore}; pub fn init( diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index c98ac0b..fae685e 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1172,6 +1172,24 @@ impl RepoStore { self.run_push(push, None, cx) } + /// Republishes the mirror and deletes grasp refs the mirror does not have. + pub fn reset_repository(&mut self, cx: &mut Context) -> Task> { + if self.pushing { + return Task::ready(Err(anyhow::anyhow!( + "A push to this repository is already in progress" + ))); + } + + let Some(announcement) = self.action_announcement(cx) else { + return self.action_error("Repository announcement is not loaded yet", cx); + }; + + let backend = Backend::global(cx); + let push = backend.update(cx, |backend, cx| backend.reset_repository(announcement, cx)); + + self.run_push(push, None, cx) + } + pub fn push_checkout( &mut self, path: PathBuf, diff --git a/crates/signed_state/src/state.rs b/crates/signed_state/src/state.rs index 901651f..58cb4e2 100644 --- a/crates/signed_state/src/state.rs +++ b/crates/signed_state/src/state.rs @@ -40,6 +40,17 @@ pub enum GitPushMode { Main, } +/// How to handle server-only refs when pushing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitPushPolicy { + /// Reconcile the local refs with the grasp servers, + /// fetching missing refs before pushing. + #[default] + Reconcile, + /// Declare the local refs only and delete server-only refs while pushing. + ResetRemote, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyncState { pub refs: Vec, @@ -47,6 +58,7 @@ pub struct SyncState { /// Local repository to push from; `None` for states parsed from events. pub path: Option, pub mode: GitPushMode, + pub policy: GitPushPolicy, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -135,6 +147,7 @@ impl SyncState { head, path: None, mode: GitPushMode::default(), + policy: GitPushPolicy::default(), } } @@ -154,6 +167,7 @@ impl SyncState { head: state.head, path: Some(path.to_path_buf()), mode: GitPushMode::default(), + policy: GitPushPolicy::default(), }) } @@ -162,13 +176,127 @@ impl SyncState { self } + pub fn with_policy(mut self, policy: GitPushPolicy) -> Self { + self.policy = policy; + self + } + + /// Reconciles the local repository with the grasp servers, fetching missing refs. + pub async fn reconcile( + &mut self, + announcement: &Announcement, + cx: &mut AsyncApp, + ) -> Result<(), Error> { + if self.policy == GitPushPolicy::ResetRemote || self.refs.is_empty() { + return Ok(()); + } + + let Some(path) = self.path.clone() else { + return Ok(()); + }; + + let local: Vec<(String, String)> = self + .refs + .iter() + .map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string())) + .collect(); + + let urls: Vec = announcement + .clone + .iter() + .map(|url| url.to_string()) + .collect(); + + let snapshot_path = path.clone(); + + let missing = cx + .background_spawn(async move { + let repo = Repo::open(&path)?; + + // Refs the servers advertise that the local repository lacks + let mut missing: Vec<(String, Vec)> = Vec::new(); + // Diverged server tips that exist only on the servers + let mut backups: Vec<(String, String, Vec)> = Vec::new(); + + for url in &urls { + let Ok(remote_refs) = repo.ls_remote(url) else { + continue; + }; + + for (name, oid) in remote_refs { + let Some((_, local_oid)) = + local.iter().find(|(local_name, _)| local_name == &name) + else { + match missing.iter_mut().find(|(known, _)| known == &name) { + Some((_, sources)) => sources.push(url.clone()), + None => missing.push((name, vec![url.clone()])), + } + continue; + }; + + if local_oid == &oid { + continue; + } + + if repo.merge_base(local_oid, &oid).is_err() { + match backups.iter_mut().find(|(known, _, _)| known == &name) { + Some((_, _, sources)) => sources.push(url.clone()), + None => { + backups.push((name.clone(), oid.clone(), vec![url.clone()])) + } + } + } + } + } + + let mut failures = Vec::new(); + + for (name, sources) in &missing { + let refspec = format!("+{name}:{name}"); + if let Err(error) = repo.fetch_refs(sources, &refspec) { + log::warn!("failed to fetch {name} from grasp servers: {error}"); + failures.push(name.clone()); + } + } + + for (name, oid, sources) in &backups { + let refspec = format!("+{name}:refs/nostr/backup/{oid}"); + if let Err(error) = repo.fetch_refs(sources, &refspec) { + log::warn!("failed to back up {name} from grasp servers: {error}"); + } + } + + match failures.is_empty() { + true => Ok(missing + .into_iter() + .map(|(name, _)| name) + .collect::>()), + false => bail!( + "could not fetch {} from any grasp server", + failures.join(", ") + ), + } + }) + .await?; + + if !missing.is_empty() { + let (mode, policy) = (self.mode, self.policy); + *self = Self::from_repo(&snapshot_path)?; + self.mode = mode; + self.policy = policy; + } + + Ok(()) + } + /// State event for these refs; the caller signs it. pub fn state_event(&self, repo_id: &str) -> EventBuilder { Self::build(repo_id, &self.refs, self.head.as_deref()) } - /// Push the state event and objects to the grasp servers, - /// calling `on_step` for each step. + /// Push the state event and objects to the grasp servers. + /// + /// Calling `on_step` for each step. pub async fn push_to_grasps

( &self, client: &Client, @@ -191,6 +319,12 @@ impl SyncState { .map_err(|error| anyhow!("could not encode the repository owner: {error}"))?; let mut outcome = PushOutcome::default(); + let policy = self.policy; + let declared: Vec = self + .refs + .iter() + .map(|git_ref| git_ref.name.clone()) + .collect(); for relay in &announcement.relays { let step = PushStep::StageStateEvent(relay.clone()); @@ -241,12 +375,24 @@ impl SyncState { let owner = owner.clone(); let repo_id = announcement.id.clone(); let mode = self.mode; + let declared = declared.clone(); async move { let repo = Repo::open(&path)?; + match mode { GitPushMode::Main => repo.push_main(&base, &owner, &repo_id), - GitPushMode::All => repo.push_all(&base, &owner, &repo_id), + GitPushMode::All => { + let mut refspecs = force_refspecs(); + if policy == GitPushPolicy::ResetRemote { + let url = format!("{base}/{owner}/{repo_id}.git"); + let server_refs = repo.ls_remote(&url)?; + refspecs.extend(deletion_refspecs(&declared, &server_refs)); + } + + let refspecs = refspecs.iter().map(String::as_str).collect::>(); + repo.push_refspecs(&base, &owner, &repo_id, &refspecs) + } } } }); @@ -370,75 +516,19 @@ impl SyncState { } } -#[cfg(test)] -mod tests { - use super::*; - - const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c"; - const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca"; - - fn git_ref(name: &str, commit: &str) -> GitRef { - GitRef::new(name, commit).expect("valid ref") - } - - fn keys() -> Keys { - Keys::new( - SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") - .expect("valid secret key"), - ) - } - - fn state_event(tags: &[&[&str]]) -> Event { - let tags: Vec = tags - .iter() - .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) - .collect(); - - EventBuilder::new(Kind::RepoState, "") - .tags(tags) - .finalize(&keys()) - .expect("signed event") - } - - #[test] - fn parses_heads_and_tags() { - let event = state_event(&[ - &["HEAD", "ref: refs/heads/main"], - &["refs/heads/main", COMMIT_A], - &["refs/heads/dev", COMMIT_B], - &["refs/tags/v1.0", COMMIT_A], - ]); - - let state = SyncState::parse(&event); - - assert_eq!(state.head.as_deref(), Some("main")); - assert_eq!( - state.refs, - vec![ - git_ref("refs/heads/main", COMMIT_A), - git_ref("refs/heads/dev", COMMIT_B), - git_ref("refs/tags/v1.0", COMMIT_A), - ] - ); - } - - #[test] - fn build_round_trips_through_parse() { - let refs = vec![ - git_ref("refs/heads/main", COMMIT_A), - git_ref("refs/heads/dev", COMMIT_B), - git_ref("refs/tags/v1.0", COMMIT_A), - ]; - - let event = SyncState::build("my-repo", &refs, Some("main")) - .finalize(&keys()) - .expect("signed event"); - - assert_eq!(event.kind, Kind::RepoState); - assert_eq!(event.tags.identifier().as_deref(), Some("my-repo")); - - let state = SyncState::parse(&event); - assert_eq!(state.refs, refs); - assert_eq!(state.head.as_deref(), Some("main")); - } +/// Refspecs to force push all heads and tags to the grasp servers. +fn force_refspecs() -> Vec { + vec![ + "+refs/heads/*:refs/heads/*".to_owned(), + "+refs/tags/*:refs/tags/*".to_owned(), + ] +} + +/// Refspecs to delete server-only refs from the grasp servers. +fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec { + server_refs + .iter() + .filter(|(name, _)| !declared.contains(name)) + .map(|(name, _)| format!(":{name}")) + .collect() } diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index fd0e7b4..ec1d3ef 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -15,10 +15,12 @@ use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{Combobox, ComboboxEvent}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::spinner::Spinner; use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, WindowExt, h_flex, + v_flex, }; use metadata::ProfileDirectory; use nostr::nips::nip19::Nip19Coordinate; @@ -72,6 +74,7 @@ pub(crate) enum RepoAction { SendPatch, About, Push, + ResetRemote, Delete, } @@ -841,6 +844,63 @@ impl RepoDetailView { self.tasks.push(task); } + pub(super) fn reset_remote(&mut self, window: &mut Window, cx: &mut Context) { + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + + let store = self.store.clone(); + let mirror = Mirrors::path(&announcement.addr()); + + let urls: Vec = announcement + .clone + .iter() + .map(|url| url.to_string()) + .collect(); + + let probe = cx.background_spawn(async move { + let mut deleted: Vec = Vec::new(); + let repo = Repo::open(&mirror)?; + + let local: HashSet = repo + .ref_state()? + .refs + .into_iter() + .map(|(name, _)| name) + .collect(); + + for url in &urls { + let Ok(remote_refs) = repo.ls_remote(url) else { + continue; + }; + for (name, _) in remote_refs { + if !local.contains(&name) && !deleted.contains(&name) { + deleted.push(name); + } + } + } + + deleted.sort(); + + Ok::<_, Error>(deleted) + }); + + cx.spawn_in(window, async move |this, cx| { + let deleted = probe.await; + + this.update_in(cx, |this, window, cx| match deleted { + Ok(deleted) => open_reset_remote_dialog(store, deleted, window, cx), + Err(error) => { + this.error = Some(error.to_string().into()); + cx.notify(); + } + })?; + + Ok::<(), Error>(()) + }) + .detach(); + } + /// Delete the repository from nostr, announcement, state and activity. pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { let task = self @@ -971,6 +1031,7 @@ impl RepoDetailView { } } RepoAction::Push => this.push_repository(window, cx), + RepoAction::ResetRemote => this.reset_remote(window, cx), RepoAction::Delete => this.delete_repository(window, cx), }), ) @@ -1173,6 +1234,16 @@ impl RepoDetailView { .child(Icon::new(CustomIconName::Init)) .child("Republish") }) + .menu_element( + Box::new(RepoAction::ResetRemote), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::RefreshCw)) + .child("Reset remote to local") + }, + ) .separator() .menu_element(Box::new(RepoAction::Delete), |_, cx| { h_flex() @@ -2043,6 +2114,75 @@ impl RepoDetailView { } } +/// Confirmation for the remote reset, listing the server refs it deletes. +fn open_reset_remote_dialog( + store: Entity, + deleted: Vec, + window: &mut Window, + cx: &mut App, +) { + const DESC: &str = "Grasp servers are overwritten to match the local mirror. \ + Refs that do not exist locally are deleted from the servers. \ + This cannot be undone."; + + window.open_dialog(cx, move |dialog, _window, _cx| { + let deleted = deleted.clone(); + let store = store.clone(); + + dialog + .w(px(480.)) + .keyboard(true) + .close_button(true) + .content(move |body, _window, cx| { + let deleted = deleted.clone(); + let store = store.clone(); + + body.child( + DialogHeader::new() + .child(DialogTitle::new().child("Reset remote to local")) + .child(DialogDescription::new().child(DESC)), + ) + .child(if deleted.is_empty() { + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No server refs will be deleted.") + .into_any_element() + } else { + v_flex() + .gap_1() + .children(deleted.iter().map(|name| { + div() + .text_sm() + .font_family(cx.theme().mono_font_family.clone()) + .child(name.clone()) + })) + .into_any_element() + }) + .child( + DialogFooter::new() + .justify_end() + .child(Button::new("cancel-reset-remote").label("Cancel").on_click( + |_, window, cx| { + window.close_dialog(cx); + }, + )) + .child( + Button::new("confirm-reset-remote") + .danger() + .label("Reset remote") + .on_click(move |_, window, cx| { + store.update(cx, |store, cx| { + store.reset_repository(cx).detach_and_log_err(cx); + }); + window.close_dialog(cx); + }), + ), + ) + }) + }); +} + fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { let owner = announcement.owner; let user = nip05