shipflow 0.1.0

A minimalist, git-aware CLI for tracking what you ship
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::error::Result;
use crate::task::LinkedCommit;

#[derive(Debug, Clone)]
pub struct GitContext {
    pub is_repo: bool,
    pub repo_root: Option<PathBuf>,
    pub branch: Option<String>,
}

#[derive(Debug, Clone)]
pub struct CommitSummary {
    pub sha: String,
    pub message: String,
}

pub fn detect(cwd: &Path) -> GitContext {
    let repo_root = repo_root_from(cwd).ok().flatten();
    let branch = repo_root
        .as_ref()
        .and_then(|root| current_branch(root).ok().flatten());

    GitContext {
        is_repo: repo_root.is_some(),
        repo_root,
        branch,
    }
}

pub fn repo_root_from(cwd: &Path) -> Result<Option<PathBuf>> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(cwd)
        .output()?;

    if !output.status.success() {
        return Ok(None);
    }

    let root = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if root.is_empty() {
        Ok(None)
    } else {
        Ok(Some(PathBuf::from(root)))
    }
}

fn current_branch(repo_root: &Path) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(repo_root)
        .output()?;

    if !output.status.success() {
        return Ok(None);
    }

    let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if branch.is_empty() || branch == "HEAD" {
        Ok(None)
    } else {
        Ok(Some(branch))
    }
}

pub fn recent_commits(repo_root: &Path, n: usize) -> Result<Vec<CommitSummary>> {
    let output = Command::new("git")
        .args(["log", &format!("-{n}"), "--pretty=format:%h|%s"])
        .current_dir(repo_root)
        .output()?;

    if !output.status.success() {
        return Ok(Vec::new());
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let commits = text
        .lines()
        .filter_map(|line| {
            let (sha, message) = line.split_once('|')?;
            Some(CommitSummary {
                sha: sha.to_owned(),
                message: message.to_owned(),
            })
        })
        .collect();

    Ok(commits)
}

pub fn validate_commit(repo_root: &Path, sha: &str) -> Result<bool> {
    let output = Command::new("git")
        .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
        .current_dir(repo_root)
        .output()?;

    Ok(output.status.success())
}

pub fn commit_message(repo_root: &Path, sha: &str) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(["log", "-1", "--pretty=format:%s", sha])
        .current_dir(repo_root)
        .output()?;

    if !output.status.success() {
        return Ok(None);
    }

    let message = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if message.is_empty() {
        Ok(None)
    } else {
        Ok(Some(message))
    }
}

pub fn linked_commit_from_sha(
    repo_root: &Path,
    sha: &str,
    branch: Option<&str>,
) -> Result<Option<LinkedCommit>> {
    if !validate_commit(repo_root, sha)? {
        return Ok(None);
    }

    let message = commit_message(repo_root, sha)?.unwrap_or_else(|| "(no message)".to_owned());
    Ok(Some(LinkedCommit {
        sha: sha.to_owned(),
        message,
        branch: branch.map(ToOwned::to_owned),
    }))
}

pub fn git_available() -> bool {
    Command::new("git")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}