migrate-guard 0.1.0

Detect and auto-renumber colliding sqlx migration version numbers across git branches.
Documentation
//! Shells to the `git` binary. No git library.

use crate::error::{GuardError, Result};
use crate::{parse_filename, MigrationFile};
use std::path::{Path, PathBuf};
use std::process::Command;

/// The `git` program + repo working directory.
pub struct GitCmd {
    pub program: String,
    pub repo: PathBuf,
}

impl GitCmd {
    pub fn new(repo: impl Into<PathBuf>) -> Self {
        Self {
            program: "git".to_string(),
            repo: repo.into(),
        }
    }

    fn run(&self, args: &[&str]) -> Result<String> {
        let out = Command::new(&self.program)
            .args(args)
            .current_dir(&self.repo)
            .output()?;
        if !out.status.success() {
            return Err(GuardError::Git {
                cmd: format!("{} {}", self.program, args.join(" ")),
                status: out.status.code(),
                stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
            });
        }
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    }
}

/// Local branch short names.
pub fn local_branches(git: &GitCmd) -> Result<Vec<String>> {
    let out = git.run(&["for-each-ref", "--format=%(refname:short)", "refs/heads/"])?;
    Ok(out
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect())
}

/// MigrationFiles of `dir` as they exist on `branch`.
pub fn branch_migration_files(
    git: &GitCmd,
    branch: &str,
    dir: &Path,
) -> Result<Vec<MigrationFile>> {
    // Verify the ref resolves to a commit first: a bad base_ref must be a loud
    // error, not silently treated as "no migrations" (which would over-renumber
    // or miss collisions). A VALID ref whose tree simply lacks `dir` returns
    // exit 0 + empty stdout from ls-tree, which is the real tolerated case.
    if git
        .run(&[
            "rev-parse",
            "--verify",
            "--quiet",
            &format!("{branch}^{{commit}}"),
        ])
        .is_err()
    {
        return Err(GuardError::Git {
            cmd: format!("git rev-parse --verify {branch}"),
            status: None,
            stderr: format!("ref '{branch}' does not resolve to a commit"),
        });
    }
    let dir_str = dir.to_string_lossy();
    let out = git.run(&["ls-tree", "-r", "--name-only", branch, "--", &dir_str])?;
    Ok(files_from_paths(&out, dir))
}

/// MigrationFiles present in the working tree's `dir` but absent from `base_ref`.
pub fn topic_new_files(git: &GitCmd, base_ref: &str, dir: &Path) -> Result<Vec<MigrationFile>> {
    let base = branch_migration_files(git, base_ref, dir)?;
    let base_names: std::collections::HashSet<_> =
        base.iter().map(|f| f.filename.clone()).collect();
    let working = crate::scan_dir("", &git.repo.join(dir))?; // role irrelevant here
    Ok(working
        .files
        .into_iter()
        .filter(|f| !base_names.contains(&f.filename))
        .collect())
}

fn files_from_paths(ls_tree_out: &str, dir: &Path) -> Vec<MigrationFile> {
    let mut files = Vec::new();
    for line in ls_tree_out.lines() {
        let p = Path::new(line.trim());
        let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if let Some((version, width, desc)) = parse_filename(name) {
            files.push(MigrationFile {
                version,
                width,
                description: desc.to_string(),
                filename: name.to_string(),
                path: dir.join(name),
            });
        }
    }
    files.sort_by_key(|f| f.version);
    files
}