gnostr-ngit 948363.1875.7190

nostr plugin for git
#![cfg_attr(not(test), warn(clippy::pedantic))]
#![allow(clippy::large_futures, clippy::module_name_repetitions)]
#![allow(dead_code)]
#![cfg_attr(not(test), warn(clippy::expect_used))]

use core::str;
use std::{
    collections::HashSet,
    env, io,
    path::{Path, PathBuf},
};

use crate::{
    client::{
        Client, Connect, FetchReport, Params, consolidate_fetch_reports, get_repo_ref_from_cache,
        is_verbose,
    },
    git::{Repo, RepoActions, nostr_url::NostrUrlDecoded, utils::set_git_timeout},
    login::existing::load_existing_login,
    utils::read_line,
};
use anyhow::{Context, Result, bail};
use nostr::nips::nip19::Nip19Coordinate;

#[derive(Default, Clone)]
struct PushOptions {
    title: Option<String>,
    description: Option<String>,
    git_server_extras: Vec<String>,
}

fn strip_git_quoting(s: &str) -> String {
    if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
        let inner = &s[1..s.len() - 1];
        let mut result = String::with_capacity(inner.len());
        let mut chars = inner.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '\\' {
                if let Some(&next) = chars.peek() {
                    chars.next();
                    result.push(next);
                } else {
                    result.push(c);
                }
            } else {
                result.push(c);
            }
        }
        result
    } else {
        s.to_string()
    }
}

fn decode_push_option_escapes(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.peek() {
                Some('n') => {
                    chars.next();
                    result.push('\n');
                }
                Some('\\') => {
                    chars.next();
                    result.push('\\');
                }
                _ => result.push(c),
            }
        } else {
            result.push(c);
        }
    }
    result
}

impl PushOptions {
    fn validate(&self) -> Result<Option<(String, String)>> {
        match (&self.title, &self.description) {
            (Some(t), Some(d)) => Ok(Some((t.clone(), d.clone()))),
            (Some(_), None) => bail!(
                "error: 'title' push-option provided without 'description'. Both title and description are required together, or neither to use defaults."
            ),
            (None, Some(_)) => bail!(
                "error: 'description' push-option provided without 'title'. Both title and description are required together, or neither to use defaults."
            ),
            (None, None) => Ok(None),
        }
    }
}

#[path = "../bin/git_remote_nostr/fetch.rs"]
mod fetch;
#[path = "../bin/git_remote_nostr/list.rs"]
mod list;
#[path = "../bin/git_remote_nostr/push.rs"]
mod push;

pub async fn run() -> Result<()> {
    crate::install_rustls_crypto_provider();

    if std::env::var("NGITTEST").is_ok() {
        unsafe { std::env::set_var("NGIT_VERBOSE", "1") };
    }

    let Some((decoded_nostr_url, git_repo)) = process_args().await? else {
        return Ok(());
    };

    let git_repo_path = git_repo.get_path()?;

    let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));

    if let Ok((signer, _, _)) = load_existing_login(
        &Some(&git_repo),
        &None,
        &None,
        &None,
        None,
        true,
        false,
        false,
    )
    .await
    {
        client.set_signer(signer).await;
    }

    let fetch_report =
        fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinate)
            .await?;

    let mut repo_ref =
        get_repo_ref_from_cache(Some(git_repo_path), &decoded_nostr_url.coordinate).await?;

    repo_ref.set_nostr_git_url(decoded_nostr_url.clone());

    let _ = set_git_timeout();

    let stdin = io::stdin();
    let mut line = String::new();

    let mut list_outputs = None;
    let mut push_options: PushOptions = PushOptions::default();
    loop {
        let tokens = read_line(&stdin, &mut line)?;

        match tokens.as_slice() {
            ["capabilities"] => {
                println!("option");
                println!("push");
                println!("fetch");
                println!("push-options");
                println!();
            }
            ["option", "verbosity"] => {
                println!("ok");
            }
            ["option", "push-option", rest @ ..] => {
                let option = strip_git_quoting(&rest.join(" "));
                let handled_by_ngit = if let Some((key, value)) = option.split_once('=') {
                    match key {
                        "title" => {
                            push_options.title = Some(decode_push_option_escapes(value));
                            true
                        }
                        "description" => {
                            push_options.description = Some(decode_push_option_escapes(value));
                            true
                        }
                        _ => false,
                    }
                } else {
                    false
                };
                if !handled_by_ngit {
                    push_options.git_server_extras.push(option);
                }
                println!("ok");
            }
            ["option", ..] => {
                println!("unsupported");
            }
            ["fetch", oid, refstr] => {
                fetch::run_fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?;
            }
            ["push", refspec] => {
                let title_description = push_options.validate()?;
                push::run_push(
                    &git_repo,
                    &repo_ref,
                    &stdin,
                    refspec,
                    &mut client,
                    list_outputs.clone(),
                    title_description,
                    push_options.git_server_extras.clone(),
                )
                .await?;
                push_options = PushOptions::default();
            }
            ["list"] => {
                list_outputs =
                    Some(list::run_list(&git_repo, &repo_ref, false, &fetch_report).await?);
            }
            ["list", "for-push"] => {
                list_outputs =
                    Some(list::run_list(&git_repo, &repo_ref, true, &fetch_report).await?);
            }
            [] => {
                return Ok(());
            }
            _ => {
                bail!(format!("unknown command: {}", line.trim().to_owned()));
            }
        }
    }
}

async fn process_args() -> Result<Option<(NostrUrlDecoded, Repo)>> {
    let args = env::args();
    let args = args.skip(1).take(2).collect::<Vec<_>>();

    if env::args().nth(1).as_deref() == Some("--version") {
        const VERSION: &str = env!("CARGO_PKG_VERSION");
        println!("v{VERSION}");
        return Ok(None);
    }

    let ([_, nostr_remote_url] | [nostr_remote_url]) = args.as_slice() else {
        println!("nostr plugin for git");
        println!("Usage:");
        println!(
            " - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier"
        );
        println!(
            " - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs"
        );
        println!(
            " - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options"
        );
        println!(" - set PR title/description via push options:");
        println!("     git push -o 'title=My PR' -o 'description=Details' -u origin pr/branch");
        println!("   for multiline descriptions, use \\n:");
        println!(
            "     git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch"
        );
        println!("- publish a repository to nostr with `ngit init`");
        return Ok(None);
    };

    let git_repo = Repo::from_path(&PathBuf::from(
        std::env::var("GIT_DIR").context("git should set GIT_DIR when remote helper is called")?,
    ))?;

    let decoded_nostr_url = NostrUrlDecoded::parse_and_resolve(nostr_remote_url, &Some(&git_repo))
        .await
        .context("invalid nostr url")?;

    Ok(Some((decoded_nostr_url, git_repo)))
}

async fn fetching_with_report_for_helper(
    git_repo_path: &Path,
    client: &Client,
    trusted_maintainer_coordinate: &Nip19Coordinate,
) -> Result<FetchReport> {
    let term = console::Term::stderr();
    let verbose = is_verbose();
    if verbose {
        term.write_line("nostr: fetching...")?;
    }
    let (relay_reports, progress_reporter) = client
        .fetch_all(
            Some(git_repo_path),
            Some(trusted_maintainer_coordinate),
            &HashSet::new(),
        )
        .await?;
    let has_errors = relay_reports.iter().any(std::result::Result::is_err);
    if !has_errors || !verbose {
        let _ = progress_reporter.clear();
    }
    let report = consolidate_fetch_reports(relay_reports);
    if report.to_string().is_empty() {
        if verbose {
            term.write_line("nostr: no updates")?;
        }
    } else {
        term.write_line(&format!("nostr updates: {report}"))?;
    }
    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_backslash_n_to_newline() {
        assert_eq!(
            decode_push_option_escapes(r"first line\nsecond line"),
            "first line\nsecond line"
        );
    }

    #[test]
    fn decode_multiple_newlines() {
        assert_eq!(
            decode_push_option_escapes(r"line1\n\nline3\nline4"),
            "line1\n\nline3\nline4"
        );
    }

    #[test]
    fn decode_double_backslash_n_to_literal_backslash_n() {
        assert_eq!(
            decode_push_option_escapes(r"keep \\n literal"),
            "keep \\n literal"
        );
    }

    #[test]
    fn decode_no_escapes_unchanged() {
        assert_eq!(
            decode_push_option_escapes("no escapes here"),
            "no escapes here"
        );
    }

    #[test]
    fn decode_trailing_backslash_preserved() {
        assert_eq!(decode_push_option_escapes(r"ends with \"), "ends with \\");
    }

    #[test]
    fn decode_backslash_followed_by_other_char_preserved() {
        assert_eq!(decode_push_option_escapes(r"a \t tab"), "a \\t tab");
    }

    #[test]
    fn decode_empty_string() {
        assert_eq!(decode_push_option_escapes(""), "");
    }

    #[test]
    fn decode_mixed_escapes() {
        assert_eq!(
            decode_push_option_escapes(r"line1\nline2\\nstill line2\nline3"),
            "line1\nline2\\nstill line2\nline3"
        );
    }

    #[test]
    fn strip_git_quoting_removes_quotes_and_unescapes() {
        assert_eq!(
            strip_git_quoting(r#""description=First line\\nSecond line""#),
            r"description=First line\nSecond line"
        );
    }

    #[test]
    fn strip_git_quoting_no_quotes_unchanged() {
        assert_eq!(
            strip_git_quoting("description=plain text"),
            "description=plain text"
        );
    }

    #[test]
    fn strip_git_quoting_then_decode_produces_newlines() {
        let git_quoted = r#""description=line1\\n\\nline2""#;
        let unquoted = strip_git_quoting(git_quoted);
        assert_eq!(unquoted, r"description=line1\n\nline2");
        let (key, value) = unquoted.split_once('=').unwrap();
        assert_eq!(key, "description");
        assert_eq!(decode_push_option_escapes(value), "line1\n\nline2");
    }

    #[test]
    fn strip_git_quoting_preserves_user_double_backslash() {
        let git_quoted = r#""description=keep \\\\n literal""#;
        let unquoted = strip_git_quoting(git_quoted);
        assert_eq!(unquoted, r"description=keep \\n literal");
        let (_, value) = unquoted.split_once('=').unwrap();
        assert_eq!(decode_push_option_escapes(value), "keep \\n literal");
    }
}