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| Format::recognized_from_path(p).is_some())
55            .collect::<Vec<_>>()
56    } else {
57        files.to_vec()
58    };
59
60    if files_to_diff.is_empty() {
61        eprintln!("No prose files changed.");
62        return Ok(false);
63    }
64
65    let mut any_diff = false;
66
67    for path in &files_to_diff {
68        // Get the old version from git
69        let old_content = match file_at_ref(git_ref, path) {
70            Ok(c) => c,
71            Err(_) => {
72                // File didn't exist at that ref (new file), skip
73                continue;
74            }
75        };
76
77        // Write old content to a temp file for sdiff
78        let tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
79        std::fs::write(tmp.path(), &old_content)?;
80
81        let fmt = format.or_else(|| Format::recognized_from_path(path));
82        let diff_output = sentence_diff(tmp.path(), path, fmt, color)?;
83
84        if !diff_output.is_empty() {
85            // Replace temp path with git ref in the header
86            let display = diff_output
87                .replace(
88                    &format!("a/{}", tmp.path().display()),
89                    &format!("a/{} ({git_ref})", path.display()),
90                )
91                .replace(
92                    &format!("b/{}", tmp.path().display()),
93                    &format!("b/{}", path.display()),
94                );
95            print!("{display}");
96            any_diff = true;
97        }
98    }
99
100    if !any_diff {
101        eprintln!("No sentence-level differences.");
102    }
103
104    Ok(any_diff)
105}