gitquick 0.2.0

Command line tool that simplifies common git workflows.
use core::fmt;
use crossterm::style::Stylize;
use git2::{Repository, Status, StatusOptions};
use std::{path::Path, process::Command};

#[derive(Clone)]
pub struct Change {
    pub path: String,
    status: git2::Status,
}

pub struct CommitLog {
    pub hash: String,
    pub message: String,
}

impl fmt::Display for CommitLog {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl fmt::Display for Change {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let status_str = match self.status {
            s if s.contains(Status::WT_NEW) => "new",
            s if s.contains(Status::WT_MODIFIED) => "modified",
            s if s.contains(Status::WT_DELETED) => "deleted",
            _ => "?",
        };
        write!(f, "{}: {}", status_str, self.path)
    }
}

pub struct BranchInfo {
    pub name: String,
    pub is_current: bool,
    pub upstream: bool,
}

impl fmt::Display for BranchInfo {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let current_marker = if self.is_current { "* " } else { "  " };
        let branch_name = if self.is_current {
            self.name.clone().green().to_string()
        } else {
            self.name.to_string()
        };
        let upstream_marker = if self.upstream {
            "".to_string()
        } else {
            " (no upstream)".red().to_string()
        };
        write!(f, "{}{}{}", current_marker, branch_name, upstream_marker)
    }
}

pub fn get_log() -> Result<Vec<CommitLog>, String> {
    let output = Command::new("git")
        .arg("log")
        .arg("--pretty=format:%H%x00%s")
        .output()
        .map_err(|e| format!("Failed to log messages: {}", e))?;
    let commits = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|s| {
            let data: Vec<&str> = s.split('\0').collect();
            CommitLog {
                hash: data[0].to_string(),
                message: data[1].to_string(),
            }
        })
        .collect();
    Ok(commits)
}

pub fn get_branches() -> Result<Vec<BranchInfo>, git2::Error> {
    let repo = get_repository()?;
    // if let Err(e) = fetch_with_prune() {
    //     eprintln!("Fetch failed: {}", e);
    // }
    let branches = repo.branches(Some(git2::BranchType::Local))?;
    let head = repo.head().ok();
    let current_branch = head.and_then(|h| h.shorthand().map(|s| s.to_string()));

    let mut branch_list = Vec::new();

    for branch in branches {
        let (branch, _) = branch?;
        let name = branch.name()?.unwrap_or("Unnamed branch").to_string();
        let upstream = branch.upstream().is_ok();

        branch_list.push(BranchInfo {
            is_current: Some(name.clone()) == current_branch,
            name,
            upstream,
        });
    }

    Ok(branch_list)
}

#[allow(dead_code)]
fn fetch_with_prune() -> Result<(), std::io::Error> {
    let status = Command::new("git").arg("fetch").arg("--prune").status()?;

    if status.success() {
        Ok(())
    } else {
        Err(std::io::Error::other("git fetch with prune failed"))
    }
}

pub fn get_repository() -> Result<Repository, git2::Error> {
    Repository::discover(".")
}

pub fn get_changes(repo: &Repository) -> (Vec<Change>, Vec<Change>) {
    let mut status_opts = StatusOptions::new();
    status_opts.include_untracked(true);
    status_opts.recurse_untracked_dirs(true);

    let statuses = match repo.statuses(Some(&mut status_opts)) {
        Ok(statuses) => statuses,
        Err(err) => {
            println!("Error fetching statuses: {}", err);
            return (Vec::new(), Vec::new());
        }
    };

    let mut untracked = Vec::new();
    let mut staged = Vec::new();

    for entry in statuses.iter() {
        if let Some(path) = entry.path() {
            let path = path.to_string();
            let status = entry.status();
            if status.intersects(Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED) {
                untracked.push(Change {
                    path: path.clone(),
                    status,
                });
            }
            if status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED)
            {
                staged.push(Change { path, status });
            }
        }
    }

    (untracked, staged)
}

pub fn add_files(selected_files: Vec<Change>, index: &mut git2::Index) -> Result<(), git2::Error> {
    for change in selected_files.iter() {
        let path = Path::new(&change.path);
        if change.status == Status::WT_DELETED {
            index.remove_path(path)?;
        } else {
            index.add_path(path)?;
        }
    }

    index.write()?;
    Ok(())
}

pub fn commit(
    repo: git2::Repository,
    mut index: git2::Index,
    message: String,
) -> Result<(), git2::Error> {
    let signature = repo.signature()?;
    let tree_oid = index.write_tree()?;
    let tree = repo.find_tree(tree_oid)?;
    let parent_commits: Vec<git2::Commit> = repo
        .head()
        .ok()
        .and_then(|head| head.peel_to_commit().ok().map(|c| vec![c]))
        .unwrap_or_default();
    let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
    repo.commit(
        Some("HEAD"),
        &signature,
        &signature,
        &message,
        &tree,
        &parent_refs,
    )?;

    Ok(())
}

pub fn checkout_branch(branch: &str) -> Result<(), git2::Error> {
    let repo = get_repository()?;

    let (object, reference) = repo.revparse_ext(branch)?;

    repo.checkout_tree(&object, None)?;

    if let Some(reference) = reference {
        let reference_name = reference
            .name()
            .ok_or_else(|| git2::Error::from_str("Invalid branch reference"))?;
        repo.set_head(reference_name)?;
    } else {
        repo.set_head_detached(object.id())?;
    }

    Ok(())
}

pub fn get_current_branch() -> Result<String, git2::Error> {
    let repo = get_repository()?;

    let head = repo.head()?;
    head.shorthand()
        .map(|s| s.to_string())
        .ok_or_else(|| git2::Error::from_str("Failed to get branch name"))
}

pub fn create_and_checkout_branch(branch_name: &str) -> Result<(), git2::Error> {
    let repo = get_repository()?;

    let head_ref = repo.head()?;
    let target_commit = head_ref.peel_to_commit()?;

    let branch = repo.branch(branch_name, &target_commit, false)?;

    let branch_ref = branch
        .get()
        .name()
        .ok_or_else(|| git2::Error::from_str("Invalid branch reference name"))?;

    let obj = repo.revparse_single(branch_ref)?;

    repo.checkout_tree(&obj, None)?;
    repo.set_head(branch_ref)?;

    Ok(())
}