monoripple 0.0.6

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
use std::collections::BTreeSet;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::process::Command;

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

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ChangeKind {
    Added,
    Modified,
    Deleted,
    Renamed { old_path: PathBuf },
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChangedFile {
    pub path: PathBuf,
    pub kind: ChangeKind,
}

pub fn changed_files(root: &Path, base: &str) -> Result<Vec<ChangedFile>> {
    let output = git(root, &["diff", "--name-status", "--find-renames", base])?;
    let mut changes = Vec::new();
    let mut known = BTreeSet::new();

    for line in output.lines() {
        let fields: Vec<_> = line.split('\t').collect();
        let Some(status) = fields.first() else {
            continue;
        };

        let change = match status.chars().next() {
            Some('A') if fields.len() >= 2 => ChangedFile {
                path: root.join(fields[1]),
                kind: ChangeKind::Added,
            },
            Some('M' | 'T') if fields.len() >= 2 => ChangedFile {
                path: root.join(fields[1]),
                kind: ChangeKind::Modified,
            },
            Some('D') if fields.len() >= 2 => ChangedFile {
                path: root.join(fields[1]),
                kind: ChangeKind::Deleted,
            },
            Some('R') if fields.len() >= 3 => ChangedFile {
                path: root.join(fields[2]),
                kind: ChangeKind::Renamed {
                    old_path: PathBuf::from(fields[1]),
                },
            },
            _ => continue,
        };
        known.insert(change.path.clone());
        changes.push(change);
    }

    let untracked = git(root, &["ls-files", "--others", "--exclude-standard"])?;
    for path in untracked.lines().filter(|path| !path.is_empty()) {
        let path = root.join(path);
        if known.insert(path.clone()) {
            changes.push(ChangedFile {
                path,
                kind: ChangeKind::Added,
            });
        }
    }

    changes.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(changes)
}

pub fn file_at(root: &Path, revision: &str, path: &Path) -> Result<Option<String>> {
    let relative = path.strip_prefix(root).unwrap_or(path);
    let object = format!("{revision}:{}", relative.to_string_lossy());
    let output = Command::new("git")
        .args(["show", &object])
        .current_dir(root)
        .output()
        .with_context(|| format!("failed to run git show for {}", relative.display()))?;

    if output.status.success() {
        return String::from_utf8(output.stdout)
            .context("git returned non-UTF-8 source")
            .map(Some);
    }

    Ok(None)
}

pub fn extract_revision(root: &Path, revision: &str) -> Result<TempDir> {
    let output = Command::new("git")
        .args(["archive", "--format=tar", revision])
        .current_dir(root)
        .output()
        .with_context(|| format!("failed to archive git revision {revision}"))?;
    if !output.status.success() {
        bail!(
            "git archive failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    let destination = tempfile::tempdir()?;
    let mut archive = tar::Archive::new(Cursor::new(output.stdout));
    for entry in archive.entries()? {
        let mut entry = entry?;
        let path = entry.path()?.into_owned();
        let contains_env_file = path.components().any(|component| {
            component
                .as_os_str()
                .to_str()
                .is_some_and(|name| name.starts_with(".env"))
        });
        if !contains_env_file {
            entry.unpack_in(destination.path())?;
        }
    }
    Ok(destination)
}

pub fn repository_root(start: &Path) -> Result<PathBuf> {
    let output = git(start, &["rev-parse", "--show-toplevel"])?;
    Ok(PathBuf::from(output.trim()))
}

fn git(root: &Path, args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .with_context(|| format!("failed to run git {}", args.join(" ")))?;

    if !output.status.success() {
        bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    String::from_utf8(output.stdout).context("git returned non-UTF-8 output")
}