git-slop 0.9.6

Deterministic repository health and maintenance-pressure analysis for humans and AI agents.
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::model::RepoMetadata;

fn git_output(repo_root: Option<&Path>, args: &[&str]) -> Result<String> {
    let mut command = Command::new("git");
    if let Some(root) = repo_root {
        command.current_dir(root);
    }
    let output = command
        .args(args)
        .output()
        .context("failed to execute git")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        bail!(
            "git {} failed{}",
            args.join(" "),
            if stderr.is_empty() {
                String::new()
            } else {
                format!(": {stderr}")
            }
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub fn resolve_repo_root() -> Result<PathBuf> {
    resolve_repo_root_from(None)
}

pub fn resolve_repo_root_from(start: Option<&Path>) -> Result<PathBuf> {
    let root = git_output(start, &["rev-parse", "--show-toplevel"])?;
    Ok(PathBuf::from(root))
}

pub fn list_tracked_files(repo_root: &Path) -> Result<Vec<String>> {
    let output = Command::new("git")
        .current_dir(repo_root)
        .args(["ls-files", "-z"])
        .output()
        .context("failed to list tracked files")?;
    if !output.status.success() {
        bail!(
            "git ls-files failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let mut paths: Vec<String> = output
        .stdout
        .split(|byte| *byte == 0)
        .filter(|raw| !raw.is_empty())
        .map(|raw| String::from_utf8_lossy(raw).replace('\\', "/"))
        .collect();
    paths.sort();
    Ok(paths)
}

fn optional_git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
    git_output(Some(repo_root), args)
        .ok()
        .filter(|value| !value.is_empty())
}

pub fn repo_metadata(repo_root: &Path) -> Result<RepoMetadata> {
    let canonical = repo_root
        .canonicalize()
        .unwrap_or_else(|_| repo_root.to_path_buf());
    let repo_name = canonical
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("repository")
        .to_string();
    let branch = optional_git_output(repo_root, &["symbolic-ref", "--short", "-q", "HEAD"]);
    let head_commit = optional_git_output(repo_root, &["rev-parse", "HEAD"]);
    let head_commit_timestamp = head_commit
        .as_ref()
        .and_then(|_| optional_git_output(repo_root, &["show", "-s", "--format=%cI", "HEAD"]));
    let git_remote_url = optional_git_output(repo_root, &["config", "--get", "remote.origin.url"]);
    let is_shallow = optional_git_output(repo_root, &["rev-parse", "--is-shallow-repository"])
        .is_some_and(|value| value == "true");
    Ok(RepoMetadata {
        repo_name,
        repo_root: canonical.to_string_lossy().into_owned(),
        branch,
        head_commit,
        head_commit_timestamp,
        git_remote_url,
        is_shallow,
    })
}

pub fn changed_files(repo_root: &Path, base: &str, head: &str) -> Result<Vec<String>> {
    let output = git_output(
        Some(repo_root),
        &["diff", "--name-only", "--diff-filter=ACMR", base, head],
    )?;
    let mut paths: Vec<String> = output
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect();
    paths.sort();
    paths.dedup();
    Ok(paths)
}