gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
use chrono::{DateTime, Local};

use crate::intent::ChangeIntent;

/// Types of Git events
#[derive(Debug, Clone, PartialEq)]
pub enum GitEventKind {
    /// Commit
    Commit,
    /// Merge commit
    Merge,
    /// Branch switch (detected from reflog)
    BranchSwitch,
}

/// Git event
#[derive(Debug, Clone)]
pub struct GitEvent {
    /// Event type
    pub kind: GitEventKind,
    /// Commit hash (abbreviated)
    pub short_hash: String,
    /// Commit message (first line)
    pub message: String,
    /// Author name
    pub author: String,
    /// Timestamp
    pub timestamp: DateTime<Local>,
    /// Number of changed files (additions)
    pub files_added: usize,
    /// Number of changed files (deletions)
    pub files_deleted: usize,
    /// List of parent commit hashes (for graph rendering)
    pub parent_hashes: Vec<String>,
    /// Branch labels (branch names pointing to this commit)
    pub branch_labels: Vec<String>,
    /// AI session ID (if part of a detected AI session)
    pub session_id: Option<u32>,
    /// Inferred change intent
    pub inferred_intent: Option<ChangeIntent>,
}

impl GitEvent {
    /// Create a new commit event
    pub fn commit(
        short_hash: String,
        message: String,
        author: String,
        timestamp: DateTime<Local>,
        files_added: usize,
        files_deleted: usize,
    ) -> Self {
        Self {
            kind: GitEventKind::Commit,
            short_hash,
            message,
            author,
            timestamp,
            files_added,
            files_deleted,
            parent_hashes: Vec::new(),
            branch_labels: Vec::new(),
            session_id: None,
            inferred_intent: None,
        }
    }

    /// Create a new merge event
    pub fn merge(
        short_hash: String,
        message: String,
        author: String,
        timestamp: DateTime<Local>,
    ) -> Self {
        Self {
            kind: GitEventKind::Merge,
            short_hash,
            message,
            author,
            timestamp,
            files_added: 0,
            files_deleted: 0,
            parent_hashes: Vec::new(),
            branch_labels: Vec::new(),
            session_id: None,
            inferred_intent: None,
        }
    }

    /// Set parent commits
    pub fn with_parents(mut self, parents: Vec<String>) -> Self {
        self.parent_hashes = parents;
        self
    }

    /// Set branch labels
    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
        self.branch_labels = labels;
        self
    }

    /// Check if labels exist
    pub fn has_labels(&self) -> bool {
        !self.branch_labels.is_empty()
    }

    /// Get relative time (e.g., "14m ago", "2h ago", "3d ago")
    pub fn relative_time(&self) -> String {
        let now = Local::now();
        let duration = now.signed_duration_since(self.timestamp);

        if duration.num_minutes() < 1 {
            "just now".to_string()
        } else if duration.num_minutes() < 60 {
            format!("{}m ago", duration.num_minutes())
        } else if duration.num_hours() < 24 {
            format!("{}h ago", duration.num_hours())
        } else if duration.num_days() < 30 {
            format!("{}d ago", duration.num_days())
        } else {
            format!("{}mo ago", duration.num_days() / 30)
        }
    }
}

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

    fn create_test_timestamp() -> DateTime<Local> {
        Local::now()
    }

    #[test]
    fn test_git_event_kind_commit_is_distinct() {
        assert_ne!(GitEventKind::Commit, GitEventKind::Merge);
        assert_ne!(GitEventKind::Commit, GitEventKind::BranchSwitch);
    }

    #[test]
    fn test_git_event_commit_creates_commit_kind() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "feat: add feature".to_string(),
            "author".to_string(),
            create_test_timestamp(),
            2,
            1,
        );
        assert_eq!(event.kind, GitEventKind::Commit);
    }

    #[test]
    fn test_git_event_commit_stores_hash() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            create_test_timestamp(),
            0,
            0,
        );
        assert_eq!(event.short_hash, "abc1234");
    }

    #[test]
    fn test_git_event_commit_stores_message() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "feat: new feature".to_string(),
            "author".to_string(),
            create_test_timestamp(),
            0,
            0,
        );
        assert_eq!(event.message, "feat: new feature");
    }

    #[test]
    fn test_git_event_commit_stores_author() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "John Doe".to_string(),
            create_test_timestamp(),
            0,
            0,
        );
        assert_eq!(event.author, "John Doe");
    }

    #[test]
    fn test_git_event_commit_stores_file_counts() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            create_test_timestamp(),
            5,
            3,
        );
        assert_eq!(event.files_added, 5);
        assert_eq!(event.files_deleted, 3);
    }

    #[test]
    fn test_git_event_merge_creates_merge_kind() {
        let event = GitEvent::merge(
            "abc1234".to_string(),
            "Merge PR #1".to_string(),
            "author".to_string(),
            create_test_timestamp(),
        );
        assert_eq!(event.kind, GitEventKind::Merge);
    }

    #[test]
    fn test_git_event_merge_has_zero_file_counts() {
        let event = GitEvent::merge(
            "abc1234".to_string(),
            "Merge PR #1".to_string(),
            "author".to_string(),
            create_test_timestamp(),
        );
        assert_eq!(event.files_added, 0);
        assert_eq!(event.files_deleted, 0);
    }

    #[test]
    fn test_relative_time_just_now() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            Local::now(),
            0,
            0,
        );
        assert_eq!(event.relative_time(), "just now");
    }

    #[test]
    fn test_relative_time_minutes() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            Local::now() - Duration::minutes(14),
            0,
            0,
        );
        assert_eq!(event.relative_time(), "14m ago");
    }

    #[test]
    fn test_relative_time_hours() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            Local::now() - Duration::hours(2),
            0,
            0,
        );
        assert_eq!(event.relative_time(), "2h ago");
    }

    #[test]
    fn test_relative_time_days() {
        let event = GitEvent::commit(
            "abc1234".to_string(),
            "message".to_string(),
            "author".to_string(),
            Local::now() - Duration::days(3),
            0,
            0,
        );
        assert_eq!(event.relative_time(), "3d ago");
    }
}