use std::{
collections::HashSet,
io::Write,
process::{Command, Stdio},
time::Duration,
};
use anyhow::{Context, Result, bail};
use indicatif::{ProgressBar, ProgressStyle};
use ngit::{
client::get_all_proposal_patch_pr_pr_update_events_from_cache,
fetch::fetch_from_git_server,
git::str_to_sha1,
git_events::{
KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
get_pr_tip_event_or_most_recent_patch_with_ancestors, tag_value,
},
repo_ref::{RepoRef, is_grasp_server_in_list},
};
use nostr::nips::nip19::Nip19;
use nostr_sdk::{EventId, FromBech32};
use crate::{
client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
git::{Repo, RepoActions},
repo_ref::get_repo_coordinates_when_remote_unknown,
};
fn run_git_fetch(remote_name: &str) -> Result<()> {
let verbose = ngit::client::is_verbose();
if verbose {
println!("fetching from {remote_name}...");
}
let spinner = if verbose {
None
} else {
let pb = ProgressBar::new_spinner()
.with_style(
ProgressStyle::with_template("{spinner} {msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"),
)
.with_message(format!("Fetching from {remote_name}..."));
pb.enable_steady_tick(Duration::from_millis(100));
Some(pb)
};
let output = Command::new("git")
.args(["fetch", remote_name])
.stdout(if verbose {
Stdio::inherit()
} else {
Stdio::piped()
})
.stderr(if verbose {
Stdio::inherit()
} else {
Stdio::piped()
})
.output()
.context("failed to run git fetch")?;
if let Some(spinner) = spinner {
spinner.finish_and_clear();
}
if !output.status.success() {
if !verbose {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("{stderr}");
}
}
bail!(
"git fetch {remote_name} exited with error: {}",
output.status
);
}
Ok(())
}
pub async fn launch(id: &str, stdout: bool, offline: bool) -> Result<()> {
let event_id = parse_event_id(id)?;
let git_repo = Repo::discover().context("failed to find a git repository")?;
let git_repo_path = git_repo.get_path()?;
let client = Client::new(ngit::client::Params::with_git_config_relay_defaults(&Some(
&git_repo,
)));
let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
let nostr_remote = git_repo
.get_first_nostr_remote_when_in_ngit_binary()
.await
.ok()
.flatten();
if !offline {
if let Some((remote_name, _)) = &nostr_remote {
run_git_fetch(remote_name)?;
} else {
fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
}
}
let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
let proposals_and_revisions: Vec<nostr::Event> =
ngit::client::get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
.await?;
let proposal = proposals_and_revisions
.iter()
.find(|e| e.id == event_id)
.context(format!(
"proposal with id {} not found in cache",
event_id.to_hex()
))?;
let commits_events: Vec<nostr::Event> = get_all_proposal_patch_pr_pr_update_events_from_cache(
git_repo_path,
&repo_ref,
&proposal.id,
)
.await?;
let patches = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone())
.context("failed to find any PR or patch events on this proposal")?;
if patches
.iter()
.any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind))
{
let pr_event = patches
.first()
.context("patch chain should contain at least one event")?;
apply_pr(&git_repo, &repo_ref, pr_event, stdout)?;
return Ok(());
}
if stdout {
output_patches_to_stdout(patches);
} else {
launch_git_am_with_patches(patches)?;
}
Ok(())
}
fn parse_event_id(id: &str) -> Result<EventId> {
if let Ok(nip19) = Nip19::from_bech32(id) {
match nip19 {
Nip19::Event(e) => return Ok(e.event_id),
Nip19::EventId(event_id) => return Ok(event_id),
_ => {}
}
}
if let Ok(event_id) = EventId::from_hex(id) {
return Ok(event_id);
}
bail!("invalid event-id or nevent: {id}")
}
fn fetch_oid_for_pr(
oid: &str,
git_repo: &Repo,
repo_ref: &RepoRef,
pr_event: &nostr::Event,
) -> Result<()> {
let git_servers = {
let mut seen: HashSet<String> = HashSet::new();
let mut out: Vec<String> = vec![];
for tag in pr_event.tags.as_slice() {
if tag.kind().eq(&nostr::event::TagKind::Clone) {
for clone_url in tag.as_slice().iter().skip(1) {
seen.insert(clone_url.clone());
out.push(clone_url.clone());
}
}
}
for server in &repo_ref.git_server {
if seen.insert(server.clone()) {
out.push(server.clone());
}
}
out
};
let term = console::Term::stderr();
for git_server_url in &git_servers {
if fetch_from_git_server(
git_repo,
&[oid.to_string()],
git_server_url,
&repo_ref.to_nostr_git_url(&None),
&term,
is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()),
)
.is_ok()
{
return Ok(());
}
}
if !git_repo.does_commit_exist(oid)? {
bail!(
"cannot find proposal git data from proposal git server hint or repository git servers"
);
}
Ok(())
}
fn apply_pr(
git_repo: &Repo,
repo_ref: &RepoRef,
pr_event: &nostr::Event,
stdout: bool,
) -> Result<()> {
let tip_oid = tag_value(pr_event, "c").context("PR event is missing 'c' (tip commit) tag")?;
if !git_repo.does_commit_exist(&tip_oid)? {
fetch_oid_for_pr(&tip_oid, git_repo, repo_ref, pr_event)?;
}
let tip = str_to_sha1(&tip_oid).context("invalid tip commit OID in PR event")?;
let base = if let Ok(merge_base_oid) = tag_value(pr_event, "merge-base") {
str_to_sha1(&merge_base_oid).context("invalid merge-base OID in PR event")?
} else {
let (_, main_tip) = git_repo
.get_main_or_master_branch()
.context("could not determine main branch to compute PR base commit")?;
let (ahead, _behind) = git_repo
.get_commits_ahead_behind(&main_tip, &tip)
.context("failed to compute commits between main and PR tip")?;
let oldest_pr_commit = ahead
.last()
.context("no commits found between main and PR tip")?;
git_repo
.get_commit_parent(oldest_pr_commit)
.context("failed to get parent of the oldest PR commit")?
};
let (commits_youngest_first, _) = git_repo
.get_commits_ahead_behind(&base, &tip)
.context("failed to enumerate commits in PR")?;
if commits_youngest_first.is_empty() {
bail!("no commits found between base and PR tip");
}
let total = commits_youngest_first.len() as u64;
let mut patch_texts: Vec<String> = Vec::with_capacity(commits_youngest_first.len());
for (i, commit) in commits_youngest_first.iter().rev().enumerate() {
let series_count = Some((i as u64 + 1, total));
let patch = git_repo
.make_patch_from_commit(commit, &series_count)
.with_context(|| format!("failed to generate patch for commit {commit}"))?;
patch_texts.push(patch);
}
if stdout {
for patch in &patch_texts {
print!("{patch}\n\n");
}
} else {
apply_patch_texts(patch_texts)?;
}
Ok(())
}
fn apply_patch_texts(patch_texts: Vec<String>) -> Result<()> {
println!("applying to current branch with `git am`");
let mut am = std::process::Command::new("git")
.arg("am")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
.context("failed to spawn git am")?;
let stdin = am
.stdin
.as_mut()
.context("git am process failed to take stdin")?;
for patch in patch_texts {
stdin
.write(format!("{patch}\n\n").as_bytes())
.context("failed to write patch content into git am stdin buffer")?;
}
stdin.flush()?;
am.wait_with_output()
.context("failed to read git am stdout")?;
Ok(())
}
fn output_patches_to_stdout(mut patches: Vec<nostr::Event>) {
patches.reverse();
for patch in patches {
print!("{}\n\n", patch.content);
}
}
fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> {
patches.reverse();
apply_patch_texts(patches.into_iter().map(|p| p.content).collect())
}