1#![deny(unsafe_code)]
10#![warn(missing_docs)]
11#![warn(clippy::unwrap_used)]
12
13use std::ffi::{OsStr, OsString};
14use std::io;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18const NO_COLOR: &str = "NO_COLOR";
22const PATHSPEC_SEPARATOR: &str = "--";
24
25pub trait Repo {
29 fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError>;
33}
34
35pub struct GitRepo {
37 cwd: PathBuf,
39 args: Vec<OsString>,
41}
42
43impl GitRepo {
44 pub fn new(cwd: impl Into<PathBuf>, args: impl Into<Vec<OsString>>) -> Self {
46 Self {
47 cwd: cwd.into(),
48 args: args.into(),
49 }
50 }
51
52 fn forwarded_args<'a>(&'a self, paths: &[&Path]) -> Vec<&'a OsStr> {
54 let args = self
55 .args
56 .iter()
57 .position(|arg| arg.as_os_str() == OsStr::new(PATHSPEC_SEPARATOR))
58 .map_or(self.args.as_slice(), |separator| &self.args[..separator]);
59
60 args.iter()
61 .map(OsString::as_os_str)
62 .filter(|arg| !is_refetched_path(arg, paths))
63 .collect()
64 }
65}
66
67impl Repo for GitRepo {
68 fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError> {
69 let forwarded = self.forwarded_args(paths);
72 let context = OsString::from(format!("-U{context_lines}"));
73 let output = Command::new("git")
74 .arg("diff")
75 .args(forwarded)
76 .arg(context)
77 .arg(PATHSPEC_SEPARATOR)
78 .args(paths)
79 .current_dir(&self.cwd)
80 .env(NO_COLOR, "1")
81 .output()?;
82 if !output.status.success() {
83 return Err(RepoError::Status(output.status.code().unwrap_or(1)));
84 }
85 Ok(output.stdout)
86 }
87}
88
89fn is_refetched_path(arg: &OsStr, paths: &[&Path]) -> bool {
91 let arg = Path::new(arg);
92 let normalized = arg.strip_prefix(".").unwrap_or(arg);
93
94 paths.iter().any(|path| {
95 let normalized_path = path.strip_prefix(".").unwrap_or(path);
96 normalized == normalized_path
97 })
98}
99
100#[derive(Debug)]
104pub enum RepoError {
105 Io(io::Error),
107 Status(i32),
109}
110
111impl From<io::Error> for RepoError {
112 fn from(error: io::Error) -> Self {
113 Self::Io(error)
114 }
115}
116
117impl std::fmt::Display for RepoError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 Self::Io(error) => write!(f, "{error}"),
121 Self::Status(code) => write!(f, "git diff exited with status {code}"),
122 }
123 }
124}
125
126impl std::error::Error for RepoError {}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn forwarded_args_strip_refetched_pathspecs_without_separator() {
135 let repo = GitRepo::new(
136 ".",
137 vec![
138 OsString::from("HEAD~14"),
139 OsString::from("HEAD"),
140 OsString::from("assets/css/style.css"),
141 ],
142 );
143 let path = Path::new("assets/css/style.css");
144
145 let args = repo.forwarded_args(&[path]);
146
147 assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
148 }
149
150 #[test]
151 fn forwarded_args_ignore_pathspecs_after_separator() {
152 let repo = GitRepo::new(
153 ".",
154 vec![
155 OsString::from("HEAD~14"),
156 OsString::from("HEAD"),
157 OsString::from("--"),
158 OsString::from("assets/css/style.css"),
159 ],
160 );
161 let path = Path::new("assets/css/style.css");
162
163 let args = repo.forwarded_args(&[path]);
164
165 assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
166 }
167}