chore: and reconcile pushes with server only refs
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
- Allow choosing the default branch when publishing a repository
|
- 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
|
- 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 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
|
### 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 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 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 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
|
### Removed
|
||||||
|
|
||||||
|
|||||||
@@ -1222,22 +1222,25 @@ impl Repo {
|
|||||||
base_url,
|
base_url,
|
||||||
owner,
|
owner,
|
||||||
repo_id,
|
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`.
|
/// 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<()> {
|
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
self.push_refspecs(
|
self.push_refspecs(
|
||||||
base_url,
|
base_url,
|
||||||
owner,
|
owner,
|
||||||
repo_id,
|
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`.
|
/// Pushes explicit refspecs to the grasp repository at `base_url`.
|
||||||
fn push_refspecs(
|
pub fn push_refspecs(
|
||||||
&self,
|
&self,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
@@ -1262,6 +1265,33 @@ impl Repo {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists branches and tags advertised by the remote at `url`.
|
||||||
|
pub fn ls_remote(&self, url: &str) -> Result<Vec<(String, String)>> {
|
||||||
|
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.
|
/// Sets the `origin` URL when the repository has no remote yet.
|
||||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||||
if self.inner.find_remote("origin").is_ok() {
|
if self.inner.find_remote("origin").is_ok() {
|
||||||
|
|||||||
@@ -122,6 +122,166 @@ fn push_all_mirrors_branches_and_tags() {
|
|||||||
assert!(refs.contains("refs/tags/v1.0"));
|
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<String> = 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]
|
#[test]
|
||||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
fn ref_state_matches_the_git_cli_ref_listing() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::git_store::Mirrors;
|
|||||||
use crate::inbox::Inbox;
|
use crate::inbox::Inbox;
|
||||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
||||||
use crate::repos::RepoListStore;
|
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 USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
@@ -588,6 +588,7 @@ impl Backend {
|
|||||||
head: Some("main".to_owned()),
|
head: Some("main".to_owned()),
|
||||||
path: Some(destination.clone()),
|
path: Some(destination.clone()),
|
||||||
mode: GitPushMode::Main,
|
mode: GitPushMode::Main,
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||||
@@ -735,7 +736,17 @@ impl Backend {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
let path = Mirrors::path(&announcement.addr());
|
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<Self>,
|
||||||
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
|
let path = Mirrors::path(&announcement.addr());
|
||||||
|
self.push_repo_from(announcement, path, None, GitPushPolicy::ResetRemote, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_checkout(
|
pub fn push_checkout(
|
||||||
@@ -745,7 +756,13 @@ impl Backend {
|
|||||||
announced_head: Option<String>,
|
announced_head: Option<String>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
self.push_repo_from(
|
||||||
|
announcement,
|
||||||
|
checkout,
|
||||||
|
announced_head,
|
||||||
|
GitPushPolicy::Reconcile,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_repo_from(
|
fn push_repo_from(
|
||||||
@@ -753,6 +770,7 @@ impl Backend {
|
|||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
announced_head: Option<String>,
|
announced_head: Option<String>,
|
||||||
|
policy: GitPushPolicy,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
@@ -789,6 +807,11 @@ impl Backend {
|
|||||||
});
|
});
|
||||||
work.await?
|
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.
|
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||||
if let Some(head) = announced_head
|
if let Some(head) = announced_head
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub use repo::RepoStore;
|
|||||||
pub use repos::RepoListStore;
|
pub use repos::RepoListStore;
|
||||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||||
use signed_nostr::NostrBackend;
|
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 use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
|
|||||||
@@ -1172,6 +1172,24 @@ impl RepoStore {
|
|||||||
self.run_push(push, None, cx)
|
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<Self>) -> Task<Result<(), Error>> {
|
||||||
|
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(
|
pub fn push_checkout(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub enum GitPushMode {
|
|||||||
Main,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SyncState {
|
pub struct SyncState {
|
||||||
pub refs: Vec<GitRef>,
|
pub refs: Vec<GitRef>,
|
||||||
@@ -47,6 +58,7 @@ pub struct SyncState {
|
|||||||
/// Local repository to push from; `None` for states parsed from events.
|
/// Local repository to push from; `None` for states parsed from events.
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
pub mode: GitPushMode,
|
pub mode: GitPushMode,
|
||||||
|
pub policy: GitPushPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -135,6 +147,7 @@ impl SyncState {
|
|||||||
head,
|
head,
|
||||||
path: None,
|
path: None,
|
||||||
mode: GitPushMode::default(),
|
mode: GitPushMode::default(),
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +167,7 @@ impl SyncState {
|
|||||||
head: state.head,
|
head: state.head,
|
||||||
path: Some(path.to_path_buf()),
|
path: Some(path.to_path_buf()),
|
||||||
mode: GitPushMode::default(),
|
mode: GitPushMode::default(),
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +176,127 @@ impl SyncState {
|
|||||||
self
|
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<String> = 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<String>)> = Vec::new();
|
||||||
|
// Diverged server tips that exist only on the servers
|
||||||
|
let mut backups: Vec<(String, String, Vec<String>)> = 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::<Vec<_>>()),
|
||||||
|
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.
|
/// State event for these refs; the caller signs it.
|
||||||
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
||||||
Self::build(repo_id, &self.refs, self.head.as_deref())
|
Self::build(repo_id, &self.refs, self.head.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the state event and objects to the grasp servers,
|
/// Push the state event and objects to the grasp servers.
|
||||||
/// calling `on_step` for each step.
|
///
|
||||||
|
/// Calling `on_step` for each step.
|
||||||
pub async fn push_to_grasps<P>(
|
pub async fn push_to_grasps<P>(
|
||||||
&self,
|
&self,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -191,6 +319,12 @@ impl SyncState {
|
|||||||
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
||||||
|
|
||||||
let mut outcome = PushOutcome::default();
|
let mut outcome = PushOutcome::default();
|
||||||
|
let policy = self.policy;
|
||||||
|
let declared: Vec<String> = self
|
||||||
|
.refs
|
||||||
|
.iter()
|
||||||
|
.map(|git_ref| git_ref.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
for relay in &announcement.relays {
|
for relay in &announcement.relays {
|
||||||
let step = PushStep::StageStateEvent(relay.clone());
|
let step = PushStep::StageStateEvent(relay.clone());
|
||||||
@@ -241,12 +375,24 @@ impl SyncState {
|
|||||||
let owner = owner.clone();
|
let owner = owner.clone();
|
||||||
let repo_id = announcement.id.clone();
|
let repo_id = announcement.id.clone();
|
||||||
let mode = self.mode;
|
let mode = self.mode;
|
||||||
|
let declared = declared.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let repo = Repo::open(&path)?;
|
let repo = Repo::open(&path)?;
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
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::<Vec<_>>();
|
||||||
|
repo.push_refspecs(&base, &owner, &repo_id, &refspecs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -370,75 +516,19 @@ impl SyncState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Refspecs to force push all heads and tags to the grasp servers.
|
||||||
mod tests {
|
fn force_refspecs() -> Vec<String> {
|
||||||
use super::*;
|
vec![
|
||||||
|
"+refs/heads/*:refs/heads/*".to_owned(),
|
||||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
"+refs/tags/*:refs/tags/*".to_owned(),
|
||||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
]
|
||||||
|
}
|
||||||
fn git_ref(name: &str, commit: &str) -> GitRef {
|
|
||||||
GitRef::new(name, commit).expect("valid ref")
|
/// Refspecs to delete server-only refs from the grasp servers.
|
||||||
}
|
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> {
|
||||||
|
server_refs
|
||||||
fn keys() -> Keys {
|
.iter()
|
||||||
Keys::new(
|
.filter(|(name, _)| !declared.contains(name))
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
.map(|(name, _)| format!(":{name}"))
|
||||||
.expect("valid secret key"),
|
.collect()
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_event(tags: &[&[&str]]) -> Event {
|
|
||||||
let tags: Vec<Tag> = 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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ use gpui_base::{Button as BaseButton, Disableable, Popover};
|
|||||||
use gpui_component::alert::Alert;
|
use gpui_component::alert::Alert;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
||||||
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{
|
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 metadata::ProfileDirectory;
|
||||||
use nostr::nips::nip19::Nip19Coordinate;
|
use nostr::nips::nip19::Nip19Coordinate;
|
||||||
@@ -72,6 +74,7 @@ pub(crate) enum RepoAction {
|
|||||||
SendPatch,
|
SendPatch,
|
||||||
About,
|
About,
|
||||||
Push,
|
Push,
|
||||||
|
ResetRemote,
|
||||||
Delete,
|
Delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +844,63 @@ impl RepoDetailView {
|
|||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn reset_remote(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = self.store.clone();
|
||||||
|
let mirror = Mirrors::path(&announcement.addr());
|
||||||
|
|
||||||
|
let urls: Vec<String> = announcement
|
||||||
|
.clone
|
||||||
|
.iter()
|
||||||
|
.map(|url| url.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let probe = cx.background_spawn(async move {
|
||||||
|
let mut deleted: Vec<String> = Vec::new();
|
||||||
|
let repo = Repo::open(&mirror)?;
|
||||||
|
|
||||||
|
let local: HashSet<String> = 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.
|
/// Delete the repository from nostr, announcement, state and activity.
|
||||||
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let task = self
|
let task = self
|
||||||
@@ -971,6 +1031,7 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RepoAction::Push => this.push_repository(window, cx),
|
RepoAction::Push => this.push_repository(window, cx),
|
||||||
|
RepoAction::ResetRemote => this.reset_remote(window, cx),
|
||||||
RepoAction::Delete => this.delete_repository(window, cx),
|
RepoAction::Delete => this.delete_repository(window, cx),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1173,6 +1234,16 @@ impl RepoDetailView {
|
|||||||
.child(Icon::new(CustomIconName::Init))
|
.child(Icon::new(CustomIconName::Init))
|
||||||
.child("Republish")
|
.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()
|
.separator()
|
||||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||||
h_flex()
|
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<RepoStore>,
|
||||||
|
deleted: Vec<String>,
|
||||||
|
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 {
|
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||||
let owner = announcement.owner;
|
let owner = announcement.owner;
|
||||||
let user = nip05
|
let user = nip05
|
||||||
|
|||||||
Reference in New Issue
Block a user