gitory-cli 0.1.0

Build a story for your project based on your git history
use anyhow::Result;
use chrono;
use git2::{DiffOptions, Oid, Repository};
use serde::{Deserialize, Serialize};

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeType {
    /// Entry does not exist in old version
    Added,
    /// Entry does not exist in new version
    Deleted,
    /// Entry is ignored or is unreadable
    Ignored,
    /// Entry content changed between old and new
    Modified,
    /// Entry content has no changed between old and new
    Unmodified,
}

impl From<git2::Delta> for ChangeType {
    fn from(value: git2::Delta) -> Self {
        match value {
            git2::Delta::Untracked | git2::Delta::Added => ChangeType::Added,
            git2::Delta::Modified
            | git2::Delta::Renamed
            | git2::Delta::Copied
            | git2::Delta::Typechange
            | git2::Delta::Conflicted => ChangeType::Modified,
            git2::Delta::Ignored | git2::Delta::Unreadable => ChangeType::Ignored,
            git2::Delta::Deleted => ChangeType::Deleted,
            git2::Delta::Unmodified => ChangeType::Unmodified,
        }
    }
}

#[derive(Debug, Clone)]
pub struct GitBasedCodeDiff {
    pub file_name: String,
    pub status: ChangeType,
    pub old_code: String,
    pub new_code: String,
}

#[derive(Debug, Clone)]
pub struct GitCommitDiff {
    pub commit_message: String,
    pub code_diffs: Vec<GitBasedCodeDiff>,
}

pub fn get_commit_oid(repo: &Repository, max_depth: usize) -> Result<(Oid, Oid)> {
    let mut revwalk = repo.revwalk().expect("Failed to create revwalk");
    revwalk.push_head().expect("Failed to push HEAD");

    let mut merge_pair: Vec<(Oid, Oid)> = Vec::new();
    let mut commits: Vec<Oid> = Vec::new();
    // Iterate over all commits
    for oid in revwalk.take(max_depth) {
        let commit_id = oid.expect("Failed to get commit OID");
        let commit = repo.find_commit(commit_id).expect("Failed to find commit");

        commits.push(commit.id());

        if commit.parent_count() > 1 {
            // This is a merge commit, indicating a branch update
            for parent_index in 1..commit.parent_count() {
                let parent_commit = commit
                    .parent(parent_index - 1)
                    .expect("Failed to get parent commit");

                merge_pair.push((parent_commit.id(), commit.id()));
            }
        }
    }

    let (parent_oid, merged_oid) = match merge_pair.last() {
        Some(x) => {
            if commits.iter().any(|y| y == &x.0) && commits.iter().any(|z| z == &x.1) {
                (commits.last().unwrap().to_owned(), commits[0])
            } else if commits.iter().any(|y| y == &x.1) && commits.iter().any(|z| z != &x.0) {
                (x.0, commits[0])
            } else {
                (x.0, x.1)
            }
        }
        None => (commits.last().unwrap().to_owned(), commits[0]),
    };

    Ok((parent_oid, merged_oid))
}

fn get_code_diff(
    repo: &Repository,
    base_oid: &Oid,
    compare_oid: &Oid,
) -> Result<Vec<GitBasedCodeDiff>> {
    let base_commit = repo
        .find_commit(*base_oid)
        .expect("Failed to find old commit");
    let compare_commit = repo
        .find_commit(*compare_oid)
        .expect("Failed to find new commit");

    // Retrieve tree objects for the commits
    let base_tree = base_commit.tree().expect("Failed to get old commit tree");
    let compare_tree = compare_commit
        .tree()
        .expect("Failed to get new commit tree");

    // Create a diff between the old and new trees
    let mut diff_options = DiffOptions::new();
    let diff = repo
        .diff_tree_to_tree(
            Some(&base_tree),
            Some(&compare_tree),
            Some(&mut diff_options),
        )
        .expect("Failed to create diff");

    let mut code_diffs: Vec<GitBasedCodeDiff> = Vec::new();

    diff.deltas().for_each(|delta| {
        code_diffs.push(GitBasedCodeDiff {
            file_name: delta
                .new_file()
                .path()
                .unwrap()
                .to_str()
                .unwrap()
                .to_owned(),
            status: delta.status().into(),
            old_code: String::new(),
            new_code: String::new(),
        })
    });

    let mut line_callback = |delta: git2::DiffDelta<'_>,
                             _hunk: Option<git2::DiffHunk<'_>>,
                             line: git2::DiffLine<'_>| {
        match code_diffs.iter_mut().find(|item| {
            item.file_name
                == delta
                    .new_file()
                    .path()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_owned()
        }) {
            Some(code_diff) => {
                let line_content = std::str::from_utf8(line.content()).unwrap_or_default();
                match line.origin_value() {
                    git2::DiffLineType::Context => {
                        code_diff.old_code.push_str(&line_content);
                        code_diff.new_code.push_str(&line_content);
                    }
                    git2::DiffLineType::Addition => {
                        code_diff.new_code.push_str(&line_content);
                    }
                    git2::DiffLineType::Deletion => {
                        code_diff.old_code.push_str(&line_content);
                    }
                    _ => {}
                }
            }
            None => {}
        }

        true
    };

    diff.foreach(
        &mut |_: git2::DiffDelta<'_>, _: f32| true,
        None,
        None,
        Some(&mut line_callback),
    )
    .expect("Failed to iterate through diff entries");

    Ok(code_diffs)
}

fn get_commit_messages(repo: &Repository, base_oid: &Oid, compare_oid: &Oid) -> Result<String> {
    // Iterate over the commit history
    let mut revwalk = repo.revwalk().expect("Failed to create revision walker");

    revwalk
        .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)
        .expect("Failed to set sorting order");

    revwalk
        .push(*compare_oid)
        .expect("Failed to push ending commit");
    revwalk
        .hide(*base_oid)
        .expect("Failed to hide starting commit");

    let mut commit_messages: Vec<String> = Vec::new();

    for oid in revwalk {
        let oid = oid.expect("Error getting OID");
        let commit = repo.find_commit(oid).expect("Error finding commit");

        commit_messages.push(commit.summary().unwrap_or("No summary").to_string());
    }

    Ok(commit_messages.join("\n"))
}

pub fn get_commit_diff(
    repo: &Repository,
    base_oid: &Oid,
    compare_oid: &Oid,
) -> Result<GitCommitDiff> {
    let commit_messages = get_commit_messages(repo, base_oid, compare_oid)?;
    let code_diffs = get_code_diff(repo, base_oid, compare_oid)?;
    Ok(GitCommitDiff {
        commit_message: commit_messages,
        code_diffs,
    })
}

pub fn get_commit_timestamps(
    repo: &Repository,
    base_oid: &Oid,
    compare_oid: &Oid,
) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> {
    let base_commit = repo
        .find_commit(*base_oid)
        .expect("Failed to find old commit");
    let compare_commit = repo
        .find_commit(*compare_oid)
        .expect("Failed to find new commit");

    let base_timestamp = base_commit.time();
    // This is timestamp in UTC
    let base_timestamp_in_seconds = base_timestamp.seconds();
    // TODO: Figure out how to include the timezone offset in the date_time timestamp
    let _offset_minutes = base_timestamp.offset_minutes();
    let _offset_sign = match base_timestamp.sign() {
        '+' => 1,
        '-' => -1,
        _ => 0,
    };
    let base_datetime = chrono::DateTime::from_timestamp(base_timestamp_in_seconds as i64, 0);

    let compare_timestamp = compare_commit.time();
    // This is timestamp in UTC
    let compare_timestamp_in_seconds = compare_timestamp.seconds();
    // TODO: Figure out how to include the timezone offset in the date_time timestamp
    let _offset_minutes = compare_timestamp.offset_minutes();
    let _offset_sign = match compare_timestamp.sign() {
        '+' => 1,
        '-' => -1,
        _ => 0,
    };
    let compare_datetime = chrono::DateTime::from_timestamp(compare_timestamp_in_seconds as i64, 0);

    Ok((base_datetime.unwrap(), compare_datetime.unwrap()))
}

pub fn get_total_commit_count(repo: &Repository) -> Result<usize> {
    let mut revwalk = repo.revwalk().expect("Failed to create revwalk");
    revwalk.push_head().expect("Failed to push HEAD");

    Ok(revwalk.count())
}