Skip to main content

snapper_fmt/
git_diff.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result};
5
6use crate::format::Format;
7use crate::sdiff::sentence_diff;
8
9/// Get the list of changed files between a git ref and the working tree.
10fn changed_files(git_ref: &str) -> Result<Vec<PathBuf>> {
11    let output = Command::new("git")
12        .args(["diff", "--name-only", git_ref])
13        .output()
14        .context("failed to run git diff --name-only")?;
15    if !output.status.success() {
16        let stderr = String::from_utf8_lossy(&output.stderr);
17        anyhow::bail!("git diff failed: {stderr}");
18    }
19    let stdout = String::from_utf8_lossy(&output.stdout);
20    Ok(stdout
21        .lines()
22        .filter(|l| !l.is_empty())
23        .map(PathBuf::from)
24        .collect())
25}
26
27/// Get the contents of a file at a specific git ref.
28fn file_at_ref(git_ref: &str, path: &Path) -> Result<String> {
29    let spec = format!("{git_ref}:{}", path.display());
30    let output = Command::new("git")
31        .args(["show", &spec])
32        .output()
33        .with_context(|| format!("failed to run git show {spec}"))?;
34    if !output.status.success() {
35        let stderr = String::from_utf8_lossy(&output.stderr);
36        anyhow::bail!("git show {spec} failed: {stderr}");
37    }
38    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
39}
40
41/// Run sentence-level diff against a git ref for one or more files.
42pub fn run_git_diff(
43    git_ref: &str,
44    files: &[PathBuf],
45    format: Option<Format>,
46    color: bool,
47) -> Result<bool> {
48    let files_to_diff = if files.is_empty() {
49        // Auto-detect changed files
50        let changed = changed_files(git_ref)?;
51        // Filter to prose file extensions
52        changed
53            .into_iter()
54            .filter(|p| {
55                matches!(
56                    Format::from_path(p),
57                    Format::Org | Format::Latex | Format::Markdown
58                )
59            })
60            .collect::<Vec<_>>()
61    } else {
62        files.to_vec()
63    };
64
65    if files_to_diff.is_empty() {
66        eprintln!("No prose files changed.");
67        return Ok(false);
68    }
69
70    let mut any_diff = false;
71
72    for path in &files_to_diff {
73        // Get the old version from git
74        let old_content = match file_at_ref(git_ref, path) {
75            Ok(c) => c,
76            Err(_) => {
77                // File didn't exist at that ref (new file), skip
78                continue;
79            }
80        };
81
82        // Write old content to a temp file for sdiff
83        let tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
84        std::fs::write(tmp.path(), &old_content)?;
85
86        let fmt = format.or_else(|| Some(Format::from_path(path)));
87        let diff_output = sentence_diff(tmp.path(), path, fmt, color)?;
88
89        if !diff_output.is_empty() {
90            // Replace temp path with git ref in the header
91            let display = diff_output
92                .replace(
93                    &format!("a/{}", tmp.path().display()),
94                    &format!("a/{} ({git_ref})", path.display()),
95                )
96                .replace(
97                    &format!("b/{}", tmp.path().display()),
98                    &format!("b/{}", path.display()),
99                );
100            print!("{display}");
101            any_diff = true;
102        }
103    }
104
105    if !any_diff {
106        eprintln!("No sentence-level differences.");
107    }
108
109    Ok(any_diff)
110}