Skip to main content

declutter/
git.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6
7use crate::review::{ChangeStatus, FileChange};
8
9/// One side of a comparison.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Side {
12    Rev(String),
13    Index,
14    Worktree,
15}
16
17/// What to compare, parsed from the command line.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RangeSpec {
20    pub old: String,
21    pub new: Side,
22    /// `a...b`: compare `b` against the merge base of `a` and `b`.
23    pub merge_base: bool,
24}
25
26impl RangeSpec {
27    /// No argument: `HEAD` against the working tree, untracked files included.
28    /// `rev`: `rev` against the working tree. `a..b`, `a...b`: as in `git diff`.
29    /// `staged`: against the index instead of the working tree.
30    pub fn parse(arg: Option<&str>, staged: bool) -> Result<RangeSpec> {
31        let rev = |s: &str| {
32            if s.is_empty() {
33                "HEAD".to_string()
34            } else {
35                s.to_string()
36            }
37        };
38        let spec = match arg {
39            Some(arg) if arg.contains("..") => {
40                if staged {
41                    bail!("--staged takes a single revision, not a range");
42                }
43                let (merge_base, (a, b)) = match arg.split_once("...") {
44                    Some(parts) => (true, parts),
45                    None => (false, arg.split_once("..").unwrap_or((arg, ""))),
46                };
47                RangeSpec {
48                    old: rev(a),
49                    new: Side::Rev(rev(b)),
50                    merge_base,
51                }
52            }
53            other => RangeSpec {
54                old: rev(other.unwrap_or("")),
55                new: if staged { Side::Index } else { Side::Worktree },
56                merge_base: false,
57            },
58        };
59        Ok(spec)
60    }
61}
62
63/// The top-level directory of the working tree containing `dir`.
64pub fn repo_root(dir: &Path) -> Result<PathBuf> {
65    let top = git(dir, &["rev-parse", "--show-toplevel"]).context("not inside a git repository")?;
66    Ok(PathBuf::from(String::from_utf8(top)?.trim_end()))
67}
68
69/// Reads every changed file in the repository containing `dir`.
70pub fn load(dir: &Path, spec: &RangeSpec) -> Result<Vec<FileChange>> {
71    let root = repo_root(dir)?;
72
73    let old_rev = if spec.merge_base {
74        // Against the working tree or the index, the branch being measured is HEAD's.
75        let tip = match &spec.new {
76            Side::Rev(new_rev) => new_rev.as_str(),
77            Side::Index | Side::Worktree => "HEAD",
78        };
79        String::from_utf8(git(&root, &["merge-base", &spec.old, tip])?)?
80            .trim()
81            .to_string()
82    } else {
83        spec.old.clone()
84    };
85
86    let mut args = vec![
87        "diff",
88        "--name-status",
89        "-z",
90        "-M",
91        "--no-color",
92        "--no-ext-diff",
93    ];
94    if spec.new == Side::Index {
95        args.push("--cached");
96    }
97    args.push(&old_rev);
98    if let Side::Rev(new_rev) = &spec.new {
99        args.push(new_rev);
100    }
101    args.push("--");
102    let listing = git(&root, &args)?;
103
104    let mut tokens = listing
105        .split(|byte| *byte == 0)
106        .filter(|token| !token.is_empty())
107        .map(|token| String::from_utf8_lossy(token).into_owned());
108    let mut changes = Vec::new();
109    while let Some(code) = tokens.next() {
110        let first = tokens
111            .next()
112            .context("unexpected `git diff --name-status` output")?;
113        let (status, old_path, path) = match code.chars().next() {
114            Some('R' | 'C') => {
115                let second = tokens
116                    .next()
117                    .context("unexpected `git diff --name-status` output")?;
118                (ChangeStatus::Renamed, Some(first), second)
119            }
120            Some('A') => (ChangeStatus::Added, None, first),
121            Some('D') => (ChangeStatus::Deleted, None, first),
122            _ => (ChangeStatus::Modified, None, first),
123        };
124
125        let old = match status {
126            ChangeStatus::Added => None,
127            _ => {
128                let old_path = old_path.as_deref().unwrap_or(&path);
129                Some(git(&root, &["show", &format!("{old_rev}:{old_path}")])?)
130            }
131        };
132        let new = match status {
133            ChangeStatus::Deleted => None,
134            _ => Some(read_side(&root, &spec.new, &path)?),
135        };
136        changes.push(file_change(path, old_path, status, old, new));
137    }
138
139    if spec.new == Side::Worktree {
140        let untracked = git(&root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
141        for path in untracked.split(|byte| *byte == 0).filter(|p| !p.is_empty()) {
142            let path = String::from_utf8_lossy(path).into_owned();
143            if !root.join(&path).is_file() {
144                continue;
145            }
146            let new = read_side(&root, &Side::Worktree, &path)?;
147            changes.push(file_change(
148                path,
149                None,
150                ChangeStatus::Added,
151                None,
152                Some(new),
153            ));
154        }
155    }
156
157    changes.sort_by(|a, b| a.path.cmp(&b.path));
158    Ok(changes)
159}
160
161fn read_side(root: &Path, side: &Side, path: &str) -> Result<Vec<u8>> {
162    match side {
163        Side::Rev(rev) => git(root, &["show", &format!("{rev}:{path}")]),
164        Side::Index => git(root, &["show", &format!(":{path}")]),
165        Side::Worktree => {
166            let full = root.join(path);
167            // Git stores a symlink as its target path; a dangling link must not fail the run.
168            if full
169                .symlink_metadata()
170                .is_ok_and(|meta| meta.file_type().is_symlink())
171            {
172                return Ok(fs::read_link(&full)?
173                    .to_string_lossy()
174                    .into_owned()
175                    .into_bytes());
176            }
177            fs::read(&full).with_context(|| format!("reading {path}"))
178        }
179    }
180}
181
182fn file_change(
183    path: String,
184    old_path: Option<String>,
185    status: ChangeStatus,
186    old: Option<Vec<u8>>,
187    new: Option<Vec<u8>>,
188) -> FileChange {
189    let (old, old_binary) = decode(old);
190    let (new, new_binary) = decode(new);
191    let binary = old_binary || new_binary;
192    FileChange {
193        path,
194        old_path,
195        status,
196        old: if binary { None } else { old },
197        new: if binary { None } else { new },
198        binary,
199    }
200}
201
202fn decode(bytes: Option<Vec<u8>>) -> (Option<String>, bool) {
203    let Some(bytes) = bytes else {
204        return (None, false);
205    };
206    if bytes.iter().take(8000).any(|byte| *byte == 0) {
207        return (None, true);
208    }
209    match String::from_utf8(bytes) {
210        Ok(text) => (Some(text), false),
211        Err(_) => (None, true),
212    }
213}
214
215pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<Vec<u8>> {
216    let output = Command::new("git")
217        .arg("-C")
218        .arg(dir)
219        .args(args)
220        .output()
221        .context("failed to run git")?;
222    if !output.status.success() {
223        bail!(
224            "git {} failed: {}",
225            args.join(" "),
226            String::from_utf8_lossy(&output.stderr).trim()
227        );
228    }
229    Ok(output.stdout)
230}