jj-hooks 0.3.3

Run pre-commit / lefthook / hk hooks against jj bookmark pushes
Documentation
use std::collections::HashSet;
use std::sync::OnceLock;

use regex::Regex;

use crate::error::{JjHooksError, Result};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UpdateType {
    MoveForward,
    MoveBackward,
    MoveSideways,
    Add,
    Delete,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BookmarkUpdate {
    pub remote: String,
    pub bookmark: String,
    pub update_type: UpdateType,
    pub old_commit: Option<String>,
    pub new_commit: Option<String>,
}

impl std::fmt::Display for BookmarkUpdate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let verb = match self.update_type {
            UpdateType::MoveForward => "Move forward",
            UpdateType::MoveBackward => "Move backward",
            UpdateType::MoveSideways => "Move sideways",
            UpdateType::Add => "Add",
            UpdateType::Delete => "Delete",
        };
        write!(f, "{verb} {}", self.bookmark)?;
        if let Some(old) = &self.old_commit {
            write!(f, " from {}", &old[..old.len().min(8)])?;
        }
        if let Some(new) = &self.new_commit {
            write!(f, " to {}", &new[..new.len().min(8)])?;
        }
        Ok(())
    }
}

struct PatternSet {
    update_type: UpdateType,
    patterns: Vec<Regex>,
}

fn patterns() -> &'static [PatternSet] {
    static PATTERNS: OnceLock<Vec<PatternSet>> = OnceLock::new();
    PATTERNS.get_or_init(|| {
        vec![
            PatternSet {
                update_type: UpdateType::MoveForward,
                patterns: vec![
                    Regex::new(
                        r"Move forward bookmark (?P<bookmark>\S+) from (?P<old_commit>\w+) to (?P<new_commit>\w+)",
                    )
                    .unwrap(),
                    Regex::new(
                        r"^\s*bookmark:\s+(?P<bookmark>\S+)\s+\[move forward from (?P<old_commit>\w+) to (?P<new_commit>\w+)\]",
                    )
                    .unwrap(),
                ],
            },
            PatternSet {
                update_type: UpdateType::MoveBackward,
                patterns: vec![
                    Regex::new(
                        r"Move backward bookmark (?P<bookmark>\S+) from (?P<old_commit>\w+) to (?P<new_commit>\w+)",
                    )
                    .unwrap(),
                    Regex::new(
                        r"^\s*bookmark:\s+(?P<bookmark>\S+)\s+\[move backward from (?P<old_commit>\w+) to (?P<new_commit>\w+)\]",
                    )
                    .unwrap(),
                ],
            },
            PatternSet {
                update_type: UpdateType::MoveSideways,
                patterns: vec![
                    Regex::new(
                        r"Move sideways bookmark (?P<bookmark>\S+) from (?P<old_commit>\w+) to (?P<new_commit>\w+)",
                    )
                    .unwrap(),
                    Regex::new(
                        r"^\s*bookmark:\s+(?P<bookmark>\S+)\s+\[move sideways from (?P<old_commit>\w+) to (?P<new_commit>\w+)\]",
                    )
                    .unwrap(),
                ],
            },
            PatternSet {
                update_type: UpdateType::Add,
                patterns: vec![
                    Regex::new(r"Add bookmark (?P<bookmark>\S+) to (?P<new_commit>\w+)").unwrap(),
                    Regex::new(
                        r"^\s*bookmark:\s+(?P<bookmark>\S+)\s+\[add to (?P<new_commit>\w+)\]",
                    )
                    .unwrap(),
                ],
            },
            PatternSet {
                update_type: UpdateType::Delete,
                patterns: vec![
                    Regex::new(r"Delete bookmark (?P<bookmark>\S+) from (?P<old_commit>\w+)")
                        .unwrap(),
                    Regex::new(
                        r"^\s*bookmark:\s+(?P<bookmark>\S+)\s+\[delete from (?P<old_commit>\w+)\]",
                    )
                    .unwrap(),
                ],
            },
        ]
    })
}

fn remote_pattern() -> &'static Regex {
    static PAT: OnceLock<Regex> = OnceLock::new();
    PAT.get_or_init(|| Regex::new(r"^Changes to push to (.+?):").unwrap())
}

pub fn parse_git_push_dry_run(output: &str) -> Result<HashSet<BookmarkUpdate>> {
    let mut updates = HashSet::new();
    let mut remote: Option<String> = None;

    for line in output.lines() {
        if let Some(caps) = remote_pattern().captures(line) {
            remote = Some(caps.get(1).unwrap().as_str().to_owned());
            continue;
        }

        for set in patterns() {
            for pat in &set.patterns {
                let Some(caps) = pat.captures(line) else {
                    continue;
                };
                let Some(remote) = remote.as_ref() else {
                    return Err(JjHooksError::Parse(
                        "saw a bookmark line before any `Changes to push to <remote>:` line".into(),
                    ));
                };
                let bookmark = caps.name("bookmark").unwrap().as_str().to_owned();
                let old_commit = caps.name("old_commit").map(|m| m.as_str().to_owned());
                let new_commit = caps.name("new_commit").map(|m| m.as_str().to_owned());
                updates.insert(BookmarkUpdate {
                    remote: remote.clone(),
                    bookmark,
                    update_type: set.update_type,
                    old_commit,
                    new_commit,
                });
            }
        }
    }

    Ok(updates)
}