use anyhow::{Context, anyhow, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn changed_files(exts: &[&str]) -> anyhow::Result<Vec<PathBuf>> {
let root_raw = git_stdout(&["rev-parse", "--show-toplevel"])?;
let root = PathBuf::from(root_raw.trim());
let mut paths = Vec::new();
for line in changed_lines(&root)? {
let p = root.join(line);
if matches_ext(&p, exts) && p.is_file() {
paths.push(p);
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn changed_lines(_root: &Path) -> anyhow::Result<Vec<String>> {
let mut paths = nul_paths(
&git_stdout_opt(&[
"diff",
"--no-relative",
"--name-only",
"--diff-filter=ACMR",
"-z",
])?
.unwrap_or_default(),
);
paths.extend(nul_paths(
&git_stdout_opt(&[
"diff",
"--no-relative",
"--cached",
"--name-only",
"--diff-filter=ACMR",
"-z",
])?
.unwrap_or_default(),
));
Ok(paths)
}
fn nul_paths(output: &str) -> Vec<String> {
output
.split('\0')
.filter(|path| !path.is_empty())
.map(String::from)
.collect()
}
fn git_stdout(args: &[&str]) -> anyhow::Result<String> {
git_stdout_opt(args)?.ok_or_else(|| anyhow!("`git {}` produced no output", args.join(" ")))
}
fn git_stdout_opt(args: &[&str]) -> anyhow::Result<Option<String>> {
let out = Command::new("git")
.args(args)
.output()
.with_context(|| "failed to run `git` (is it installed and on PATH?)")?;
if !out.status.success() {
bail!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
}
fn matches_ext(p: &Path, exts: &[&str]) -> bool {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| exts.contains(&e))
}