gnostr-ngit 948363.1875.7190

nostr plugin for git
use std::{collections::HashMap, sync::Arc};

use anyhow::{Context, Result};
use git2::Oid;
use nostr::{
    event::{EventBuilder, Tag},
    signer::NostrSigner,
};

use crate::client::{STATE_KIND, sign_event};

pub struct RepoState {
    pub identifier: String,
    pub state: HashMap<String, String>,
    pub event: nostr::Event,
}

impl RepoState {
    pub fn try_from(mut state_events: Vec<nostr::Event>) -> Result<Self> {
        state_events.sort_by_key(|e| e.created_at);
        let event = state_events.last().context("no state events")?;
        let mut state = HashMap::new();
        for tag in event.tags.iter() {
            if let Some(name) = tag.as_slice().first() {
                // include ^{} peeled refs for annotated tags: git requires
                // both "<tag-oid> refs/tags/v1.0.0" and
                // "<commit-oid> refs/tags/v1.0.0^{}" in the list output so
                // it can resolve the tag to a commit. without the ^{} line
                // git fetch --prune deletes the tag as unresolvable.
                if ["refs/heads/", "refs/tags", "refs/notes/", "HEAD"]
                    .iter()
                    .any(|s| name.starts_with(*s))
                {
                    if let Some(value) = tag.as_slice().get(1) {
                        if Oid::from_str(value).is_ok() || value.contains("ref: refs/") {
                            state.insert(name.to_owned(), value.to_owned());
                        }
                    }
                }
            }
        }
        add_head(&mut state);
        Ok(RepoState {
            identifier: event
                .tags
                .identifier()
                .context("existing event must have an identifier")?
                .to_string(),
            state,
            event: event.clone(),
        })
    }

    pub async fn build(
        identifier: String,
        mut state: HashMap<String, String>,
        signer: &Arc<dyn NostrSigner>,
    ) -> Result<Self> {
        add_head(&mut state);
        let mut tags = vec![Tag::identifier(identifier.clone())];
        for (name, value) in &state {
            tags.push(Tag::custom(
                nostr_sdk::TagKind::Custom(name.into()),
                vec![value.clone()],
            ));
        }
        let event = sign_event(
            EventBuilder::new(STATE_KIND, "").tags(tags),
            signer,
            "git state".to_string(),
        )
        .await?;
        Ok(RepoState {
            identifier,
            state,
            event,
        })
    }
}

// Include a HEAD if one isn't listed to prevent errors when users git config
// default branch isn't in the state event
fn add_head(state: &mut HashMap<String, String>) {
    if !state.contains_key("HEAD") {
        if state.contains_key("refs/heads/master") {
            state.insert("HEAD".to_string(), "ref: refs/heads/master".to_string());
        } else if state.contains_key("refs/heads/main") {
            state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
        } else if let Some(k) = state.keys().find(|k| k.starts_with("refs/heads/")) {
            state.insert("HEAD".to_string(), format!("ref: {k}"));
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;
    use test_utils::TEST_KEY_1_SIGNER;

    #[tokio::test]
    async fn notes_refs_round_trip_through_repo_state() {
        let mut state = HashMap::new();
        state.insert(
            "refs/heads/main".to_string(),
            "9ee507fc4357d7ee16a5d8901bedcd103f23c17d".to_string(),
        );
        state.insert(
            "refs/notes/commits".to_string(),
            "9ee507fc4357d7ee16a5d8901bedcd103f23c17d".to_string(),
        );

        let repo_state = RepoState::build("example".to_string(), state, &TEST_KEY_1_SIGNER)
            .await
            .unwrap();
        let round_tripped = RepoState::try_from(vec![repo_state.event]).unwrap();

        assert!(round_tripped.state.contains_key("refs/notes/commits"));
        assert!(round_tripped.state.contains_key("HEAD"));
        assert_eq!(round_tripped.identifier, "example");
    }
}