g-cli 0.1.0

Git that talks back. A human-friendly CLI wrapper for Git.
use std::process::Command;

pub struct GitResult {
    pub stdout: String,
    pub stderr: String,
    pub success: bool,
}

pub fn run(args: &[&str]) -> GitResult {
    let output = Command::new("git")
        .args(args)
        .output()
        .expect("Failed to execute git. Is git installed?");

    GitResult {
        stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
        success: output.status.success(),
    }
}

pub fn current_branch() -> String {
    let result = run(&["branch", "--show-current"]);
    if result.success && !result.stdout.is_empty() {
        result.stdout
    } else {
        "HEAD (detached)".to_string()
    }
}

pub fn is_git_repo() -> bool {
    run(&["rev-parse", "--is-inside-work-tree"]).success
}

pub struct FileStatus {
    pub path: String,
    pub staged: bool,
    pub kind: FileChangeKind,
}

#[derive(Clone, Copy)]
pub enum FileChangeKind {
    Modified,
    Added,
    Deleted,
    Renamed,
    Untracked,
}

impl FileChangeKind {
    pub fn icon(&self) -> &str {
        match self {
            FileChangeKind::Modified => "~",
            FileChangeKind::Added => "+",
            FileChangeKind::Deleted => "-",
            FileChangeKind::Renamed => ">",
            FileChangeKind::Untracked => "?",
        }
    }

    pub fn label(&self) -> &str {
        match self {
            FileChangeKind::Modified => "modified",
            FileChangeKind::Added => "added",
            FileChangeKind::Deleted => "deleted",
            FileChangeKind::Renamed => "renamed",
            FileChangeKind::Untracked => "untracked",
        }
    }
}

pub fn parse_status() -> Vec<FileStatus> {
    let result = run(&["status", "--porcelain=v1"]);
    if !result.success || result.stdout.is_empty() {
        return vec![];
    }

    result
        .stdout
        .lines()
        .filter_map(|line| {
            if line.len() < 4 {
                return None;
            }
            let index = line.as_bytes()[0];
            let worktree = line.as_bytes()[1];
            let path = line[3..].to_string();

            // Staged changes (index column)
            if index != b' ' && index != b'?' {
                let kind = match index {
                    b'M' => FileChangeKind::Modified,
                    b'A' => FileChangeKind::Added,
                    b'D' => FileChangeKind::Deleted,
                    b'R' => FileChangeKind::Renamed,
                    _ => FileChangeKind::Modified,
                };
                return Some(FileStatus {
                    path: path.clone(),
                    staged: true,
                    kind,
                });
            }

            // Unstaged / untracked (worktree column)
            let kind = match worktree {
                b'M' => FileChangeKind::Modified,
                b'D' => FileChangeKind::Deleted,
                b'?' => FileChangeKind::Untracked,
                _ => return None,
            };
            Some(FileStatus {
                path,
                staged: false,
                kind,
            })
        })
        .collect()
}

/// Returns (ahead, behind) relative to upstream
pub fn ahead_behind() -> Option<(usize, usize)> {
    let result = run(&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
    if !result.success {
        return None;
    }
    let parts: Vec<&str> = result.stdout.split_whitespace().collect();
    if parts.len() == 2 {
        let ahead = parts[0].parse().unwrap_or(0);
        let behind = parts[1].parse().unwrap_or(0);
        Some((ahead, behind))
    } else {
        None
    }
}

pub struct LogEntry {
    #[allow(dead_code)]
    pub hash: String,
    pub subject: String,
    pub relative_time: String,
    pub is_merge: bool,
}

pub fn recent_log(count: usize) -> Vec<LogEntry> {
    let n = format!("-{}", count);
    let result = run(&["log", &n, "--pretty=format:%h|%s|%cr|%P"]);
    if !result.success || result.stdout.is_empty() {
        return vec![];
    }

    result
        .stdout
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(4, '|').collect();
            if parts.len() < 4 {
                return None;
            }
            let parents: Vec<&str> = parts[3].split_whitespace().collect();
            Some(LogEntry {
                hash: parts[0].to_string(),
                subject: parts[1].to_string(),
                relative_time: parts[2].to_string(),
                is_merge: parents.len() > 1,
            })
        })
        .collect()
}

pub fn branch_exists(name: &str) -> bool {
    let result = run(&["rev-parse", "--verify", name]);
    result.success
}

pub struct GraphCommit {
    pub hash: String,
    pub short_hash: String,
    pub subject: String,
    pub parents: Vec<String>,
}

pub fn commit_graph(max_count: usize) -> Vec<GraphCommit> {
    let n = format!("-{}", max_count);
    // %H = full hash, %h = short hash, %s = subject, %P = parent full hashes
    let result = run(&["log", "--all", &n, "--pretty=format:%H|%h|%s|%P"]);
    if !result.success || result.stdout.is_empty() {
        return vec![];
    }

    result
        .stdout
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(4, '|').collect();
            if parts.len() < 4 {
                return None;
            }
            let parents: Vec<String> = parts[3]
                .split_whitespace()
                .map(|s| s.to_string())
                .collect();
            Some(GraphCommit {
                hash: parts[0].to_string(),
                short_hash: parts[1].to_string(),
                subject: parts[2].to_string(),
                parents,
            })
        })
        .collect()
}

/// Returns a map of full commit hash -> list of branch names pointing at it
pub fn branch_tips() -> Vec<(String, String)> {
    let result = run(&[
        "for-each-ref",
        "--format=%(objectname) %(refname:short)",
        "refs/heads/",
    ]);
    if !result.success || result.stdout.is_empty() {
        return vec![];
    }
    result
        .stdout
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(2, ' ').collect();
            if parts.len() == 2 {
                Some((parts[0].to_string(), parts[1].to_string()))
            } else {
                None
            }
        })
        .collect()
}

pub fn head_hash() -> Option<String> {
    let result = run(&["rev-parse", "HEAD"]);
    if result.success && !result.stdout.is_empty() {
        Some(result.stdout)
    } else {
        None
    }
}

pub fn last_action_type() -> Option<String> {
    // Check reflog for last action
    let result = run(&["reflog", "-1", "--pretty=format:%gs"]);
    if result.success && !result.stdout.is_empty() {
        Some(result.stdout)
    } else {
        None
    }
}

pub fn last_commit_message() -> Option<String> {
    let result = run(&["log", "-1", "--pretty=format:%s"]);
    if result.success && !result.stdout.is_empty() {
        Some(result.stdout)
    } else {
        None
    }
}

pub fn reflog_entries(count: usize) -> Vec<(String, String, String)> {
    let n = format!("-{}", count);
    let result = run(&["reflog", &n, "--pretty=format:%h|%gs|%cr"]);
    if !result.success || result.stdout.is_empty() {
        return vec![];
    }
    result
        .stdout
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(3, '|').collect();
            if parts.len() < 3 {
                return None;
            }
            Some((
                parts[0].to_string(),
                parts[1].to_string(),
                parts[2].to_string(),
            ))
        })
        .collect()
}