From ee50baf800f4cb46d17858ba87a3648bb084d8b9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 25 Jul 2025 23:53:40 +0100 Subject: feat(sync): add cmd to sync git servers with nostr state. optionally use 'force' flag --- src/bin/ngit/sub_commands/sync.rs | 161 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/bin/ngit/sub_commands/sync.rs (limited to 'src/bin/ngit/sub_commands/sync.rs') diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs new file mode 100644 index 0000000..c1a3484 --- /dev/null +++ b/src/bin/ngit/sub_commands/sync.rs @@ -0,0 +1,161 @@ +use anyhow::{Context, Result}; +use ngit::{ + client::{ + Client, Connect, Params, fetching_with_report, get_repo_ref_from_cache, + get_state_from_cache, + }, + git::{Repo, RepoActions}, + list::{get_ahead_behind, list_from_remotes}, + push::push_to_remote, + repo_ref::get_repo_coordinates_when_remote_unknown, + utils::get_short_git_server_name, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + /// force push updates and delete refs from non-grasp git servers + #[arg(long, action)] + force: bool, +} + +#[allow(clippy::too_many_lines)] +pub async fn launch(args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + + let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + + let (nostr_remote_name, decoded_nostr_url) = git_repo + .get_first_nostr_remote_when_in_ngit_binary() + .await.context("failed to list git remotes")? + .context("no `nostr://` remote detected. `ngit sync` must be run from a repo with a nostr remote")?; + + let repo_coordinate = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; + + let _ = fetching_with_report(git_repo_path, &client, &repo_coordinate).await?; + + // TODO push announcement event, then state event to grasps + + let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await?; + + let nostr_state = get_state_from_cache(Some(git_repo_path), &repo_ref).await?; + + let term = console::Term::stderr(); + + let remote_states = list_from_remotes( + &term, + &git_repo, + &repo_ref.git_server, + &repo_ref.to_nostr_git_url(&None), + &repo_ref.grasp_servers(), + ); + + for (url, (remote_state, is_grasp_server)) in &remote_states { + let remote_name = get_short_git_server_name(&git_repo, url); + let mut refspecs = vec![]; + // delete ref from remote + let mut not_deleted = vec![]; + for remote_ref_name in remote_state.keys() { + if (!remote_ref_name.starts_with("refs/heads/pr/") + && (remote_ref_name.starts_with("refs/heads/") + || remote_ref_name.starts_with("refs/tags/"))) + && !nostr_state + .state + .keys() + .any(|nostr_ref| nostr_ref.eq(remote_ref_name)) + { + if *is_grasp_server || args.force { + // delete branches / tags not on nostr + refspecs.push(format!(":{remote_ref_name}")); + } else { + not_deleted.push(remote_ref_name); + } + } + } + // add or update ref on remote + let mut not_updated = vec![]; + for nostr_ref_name in nostr_state.state.keys() { + if nostr_ref_name.starts_with("refs/heads/") + || nostr_ref_name.starts_with("refs/tags/") + || !nostr_ref_name.starts_with("refs/heads/pr/") + { + // ensure nostr_state only supports refs/heads and refs/tags/ + // and not refs/heads/prs/* + } else if let Some(remote_ref_value) = remote_state.get(nostr_ref_name) { + // update ref + let force_required = { + if let Ok((ahead, _)) = + get_ahead_behind(&git_repo, nostr_ref_name, remote_ref_value) + { + !ahead.is_empty() + } else { + true + } + }; + if nostr_state + .state + .get(nostr_ref_name) + .is_none_or(|nostr_ref_value| nostr_ref_value.eq(remote_ref_value)) + { + // no action if ref in sync + } else if remote_ref_value.starts_with("ref ") && !(args.force || *is_grasp_server) + { + // dont try and sync push symbolic refs + } else if !force_required { + refspecs.push(format!( + "refs/remotes/{nostr_remote_name}/{nostr_ref_name}:{nostr_ref_name}", + )); + } else if *is_grasp_server || args.force { + refspecs.push(format!( + "+refs/remotes/{nostr_remote_name}/{nostr_ref_name}:{nostr_ref_name}", + )); + } else { + not_updated.push(nostr_ref_name); + } + } else { + // add missing refs + refspecs.push(format!( + "refs/remotes/{nostr_remote_name}/{nostr_ref_name}:{nostr_ref_name}", + )); + } + } + + if refspecs.is_empty() { + if !not_updated.is_empty() || !not_deleted.is_empty() { + term.write_line(&format!("{remote_name} in sync excluding"))?; + } else { + term.write_line(&format!("{remote_name} already in sync"))?; + } + // report already in sync + } else if let Err(error) = push_to_remote( + &git_repo, + url, + &decoded_nostr_url, + &refspecs, + &term, + *is_grasp_server, + ) { + term.write_line(&format!( + "error pushing updates to {remote_name}: error: {error}" + ))?; + } else if *is_grasp_server || args.force { + term.write_line(&format!("{remote_name} sync completed"))?; + // TODO we only know if there was an error but not if it + // rejected any updates + } else { + // we should report on refs not force pushed + term.write_line(&format!("{remote_name} sync completed"))?; + } + for name in ¬_deleted { + term.write_line(&format!(" - {name} not deleted"))?; + } + for name in ¬_updated { + term.write_line(&format!(" - {name} not updated due to conflicts"))?; + } + if !not_updated.is_empty() || !not_deleted.is_empty() { + term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?; + } + } + + Ok(()) +} -- cgit v1.2.3