1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use git2::DiffOptions;
6use git2::Patch;
7use git2::Repository;
8use rootcause::prelude::ResultExt;
9use rootcause::report;
10use ytil_cmd::CmdExt;
11
12const PATH_LINE_PREFIX: &str = "diff --git ";
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct FileDiffStats {
17 pub path: PathBuf,
19 pub added: usize,
21 pub removed: usize,
23}
24
25pub fn get_raw(path: Option<&Path>) -> rootcause::Result<String> {
33 let mut args = vec!["diff".into(), "-U0".into()];
34
35 if let Some(path) = path {
36 args.push(path.display().to_string());
37 }
38
39 let output = Command::new("git").args(args).exec()?;
40
41 ytil_cmd::extract_success_output(&output)
42}
43
44pub fn get_line_stats(repo_root: &Path) -> rootcause::Result<Vec<FileDiffStats>> {
52 let repo = Repository::open(repo_root)
53 .context("error opening repository")
54 .attach_with(|| format!("repo_root={}", repo_root.display()))?;
55 let head_tree = repo
56 .head()
57 .context("error reading repository HEAD")
58 .attach_with(|| format!("repo_root={}", repo_root.display()))?
59 .peel_to_tree()
60 .context("error reading repository HEAD tree")
61 .attach_with(|| format!("repo_root={}", repo_root.display()))?;
62 let diff = repo
63 .diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut DiffOptions::new()))
64 .context("error creating repository worktree diff")
65 .attach_with(|| format!("repo_root={}", repo_root.display()))?;
66
67 let mut out = Vec::with_capacity(diff.deltas().len());
68 for (idx, delta) in diff.deltas().enumerate() {
69 let Some(file_patch) = Patch::from_diff(&diff, idx)
70 .context("error creating file diff patch")
71 .attach_with(|| format!("repo_root={} diff_idx={idx}", repo_root.display()))?
72 else {
73 continue;
75 };
76
77 let Some(changed_path) = delta.new_file().path().or_else(|| delta.old_file().path()) else {
78 continue;
79 };
80 let (_, added, removed) = file_patch
81 .line_stats()
82 .context("error reading file diff line statistics")
83 .attach_with(|| format!("repo_root={} path={}", repo_root.display(), changed_path.display()))?;
84
85 out.push(FileDiffStats {
86 path: changed_path.to_path_buf(),
87 added,
88 removed,
89 });
90 }
91
92 Ok(out)
93}
94
95pub fn get_hunks(raw_diff_output: &str) -> rootcause::Result<Vec<(&str, usize)>> {
103 let lines: Vec<&str> = raw_diff_output.lines().collect();
104
105 let mut out = Vec::with_capacity(lines.len().saturating_div(4).max(1));
107
108 for (raw_diff_line_idx, raw_diff_line) in lines.iter().enumerate() {
109 let Some(path_line) = raw_diff_line.strip_prefix(PATH_LINE_PREFIX) else {
110 continue;
111 };
112
113 let path_idx = path_line
114 .find(" b/")
115 .ok_or_else(|| report!("error missing path prefix in path_line"))
116 .attach_with(|| {
117 format!("path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}")
118 })?
119 .saturating_add(3);
120
121 let path = path_line.get(path_idx..)
122 .ok_or_else(|| report!("error extracting path from path_line"))
123 .attach_with(|| format!("path_idx={path_idx} path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}"))?;
124
125 let lnum_lines_start_idx = raw_diff_line_idx.saturating_add(1);
126 let maybe_lnum_lines = lines
127 .get(lnum_lines_start_idx..)
128 .ok_or_else(|| report!("error extracting lnum_lines from raw_diff_output"))
129 .attach_with(|| {
130 format!("lnum_lines_start_idx={lnum_lines_start_idx} raw_diff_line_idx={raw_diff_line_idx}")
131 })?;
132
133 for maybe_lnum_line in maybe_lnum_lines {
134 if maybe_lnum_line.starts_with(PATH_LINE_PREFIX) {
135 break;
136 }
137 if !maybe_lnum_line.starts_with("@@ ") {
138 continue;
139 }
140
141 let lnum = extract_new_lnum_value(maybe_lnum_line)?;
142
143 out.push((path, lnum));
144 }
145 }
146
147 Ok(out)
148}
149
150fn extract_new_lnum_value(lnum_line: &str) -> rootcause::Result<usize> {
157 let new_lnum = lnum_line
158 .split(' ')
159 .nth(2)
160 .ok_or_else(|| report!("error missing new_lnum from lnum_line after split by space"))
161 .attach_with(|| format!("lnum_line={lnum_line:?}"))?;
162
163 let new_lnum_value = new_lnum
164 .split(',')
165 .next()
166 .and_then(|s| {
167 let trimmed = s.trim_start_matches('+');
168 if trimmed.is_empty() { None } else { Some(trimmed) }
169 })
170 .ok_or_else(|| report!("error malformed new_lnum in lnum_line"))
171 .attach_with(|| format!("lnum_line={lnum_line:?}"))?;
172
173 Ok(new_lnum_value
174 .parse::<usize>()
175 .context("error parsing new_lnum value as usize")
176 .attach_with(|| format!("lnum_value={new_lnum_value:?} lnum_line={lnum_line:?}"))?)
177}
178
179#[cfg(test)]
180mod tests {
181 use std::fs;
182
183 use rstest::rstest;
184 use test_that::prelude::*;
185
186 use super::*;
187
188 #[rstest]
189 #[case::single_file_single_hunk(
190 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -42,7 +42,7 @@",
191 vec![("src/main.rs", 42)]
192 )]
193 #[case::multiple_files(
194 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\ndiff --git a/src/lib.rs b/src/lib.rs\nindex fedcba9..7654321 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -20,3 +20,3 @@",
195 vec![("src/main.rs", 10), ("src/lib.rs", 20)]
196 )]
197 #[case::multiple_hunks_same_file(
198 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\n@@ -50,2 +50,2 @@",
199 vec![("src/main.rs", 10), ("src/main.rs", 50)]
200 )]
201 #[case::empty_input("", vec![])]
202 #[case::no_hunks(
203 "diff --git a/src/main.rs b/src/main.rs",
204 vec![]
205 )]
206 #[case::non_diff_lines_ignored(
207 "index 123..456 789\ndiff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -42,7 +42,7 @@",
208 vec![("src/main.rs", 42)]
209 )]
210 #[case::multiple_files_with_multiple_hunks(
211 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\n@@ -50,2 +50,2 @@\ndiff --git a/src/lib.rs b/src/lib.rs\nindex fedcba9..7654321 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -20,3 +20,3 @@\n@@ -60,1 +60,1 @@",
212 vec![("src/main.rs", 10), ("src/main.rs", 50), ("src/lib.rs", 20), ("src/lib.rs", 60)]
213 )]
214 fn test_get_hunks_success(#[case] input: &str, #[case] expected: Vec<(&str, usize)>) {
215 assert_that!(get_hunks(input), ok(eq(expected)));
216 }
217
218 #[rstest]
219 #[case::missing_b_delimiter("diff --git a/src/main.rs", "error missing path prefix")]
220 #[case::invalid_lnum(
221 "diff --git a/src/main.rs b/src/main.rs\n@@ -abc,5 +abc,5 @@",
222 "error parsing new_lnum value"
223 )]
224 fn test_get_hunks_error(#[case] input: &str, #[case] expected_error_contains: &str) {
225 assert_that!(
226 (get_hunks(input)).map(|_| ()),
227 err(displays_as(contains_substring(expected_error_contains)))
228 );
229 }
230
231 #[rstest]
232 #[case::standard("@@ -42,7 +42,7 @@", 42)]
233 #[case::without_plus("@@ -42,7 42,7 @@", 42)]
234 #[case::without_comma("@@ -42,7 +42 @@", 42)]
235 #[case::without_plus_or_comma("@@ -42,7 42 @@", 42)]
236 fn test_extract_new_lnum_value_when_valid_lnum_line_returns_correct_usize(
237 #[case] input: &str,
238 #[case] expected: usize,
239 ) {
240 assert_that!(extract_new_lnum_value(input), ok(eq(expected)));
241 }
242
243 #[rstest]
244 #[case::missing_new_lnum_part("@@ -42,7", "error missing new_lnum from lnum_line after split by space")]
245 #[case::malformed_lnum("@@ -42,7 +,7 @@", "error malformed new_lnum in lnum_line")]
246 #[case::lnum_value_not_numeric("@@ -42,7 +abc,7 @@", "error parsing new_lnum value as usize")]
247 fn test_extract_new_lnum_value_when_input_invalid_returns_expected_error(
248 #[case] input: &str,
249 #[case] expected_error_contains: &str,
250 ) {
251 assert_that!(
252 (extract_new_lnum_value(input)).map(|_| ()),
253 err(displays_as(contains_substring(expected_error_contains)))
254 );
255 }
256
257 #[test]
258 fn test_get_line_stats_when_staged_and_unstaged_changes_exist_includes_both() {
259 let (temp_dir, repo) = crate::tests::init_test_repo(None);
260 let relative_path = Path::new("src/main.rs");
261 let absolute_path = temp_dir.path().join(relative_path);
262
263 fs::create_dir_all(absolute_path.parent().unwrap()).unwrap();
264 fs::write(&absolute_path, "one\n").unwrap();
265 commit_file(&repo, relative_path);
266
267 fs::write(&absolute_path, "one\ntwo\n").unwrap();
268 stage_file(&repo, relative_path);
269 fs::write(&absolute_path, "one\ntwo\nthree\n").unwrap();
270
271 assert_that!(
272 get_line_stats(temp_dir.path()),
273 ok(eq(vec![FileDiffStats {
274 path: relative_path.into(),
275 added: 2,
276 removed: 0,
277 }]))
278 );
279 }
280
281 fn commit_file(repo: &Repository, relative_path: &Path) {
282 stage_file(repo, relative_path);
283 let tree_id = repo.index().unwrap().write_tree().unwrap();
284 let tree = repo.find_tree(tree_id).unwrap();
285 let signature = git2::Signature::now("test", "test@example.com").unwrap();
286 let parent = repo.head().unwrap().peel_to_commit().unwrap();
287 repo.commit(Some("HEAD"), &signature, &signature, "add file", &tree, &[&parent])
288 .unwrap();
289 }
290
291 fn stage_file(repo: &Repository, relative_path: &Path) {
292 let mut index = repo.index().unwrap();
293 index.add_path(relative_path).unwrap();
294 index.write().unwrap();
295 }
296}