Skip to main content

ytil_git/
diff.rs

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/// Line additions and removals for one changed file.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct FileDiffStats {
17    /// Path relative to the repository root.
18    pub path: PathBuf,
19    /// Number of added lines.
20    pub added: usize,
21    /// Number of removed lines.
22    pub removed: usize,
23}
24
25/// Retrieves the current `git diff` raw output with `-U0` as a single `String`.
26///
27/// Callers should pass the returned string to [`get_hunks`] which splits into lines internally,
28/// avoiding per-line `String` allocations.
29///
30/// # Errors
31/// - `git diff` command fails.
32pub 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
44/// Retrieves line additions and removals for tracked files changed from `HEAD`.
45///
46/// The repository's `HEAD` tree is compared with its index and working tree. Staged and unstaged
47/// changes are included. Untracked files and binary files do not produce line statistics.
48///
49/// # Errors
50/// - The repository, its `HEAD` tree, or its working tree diff cannot be read.
51pub 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            // Binary and unchanged files have no line statistics.
74            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
95/// Extracts file paths and starting line numbers of hunks from raw `git diff` output.
96///
97/// Accepts a `&str` (the full diff output) and splits into lines internally, avoiding
98/// per-line `String` allocations.
99///
100/// # Errors
101/// - Parsing diff output fails.
102pub fn get_hunks(raw_diff_output: &str) -> rootcause::Result<Vec<(&str, usize)>> {
103    let lines: Vec<&str> = raw_diff_output.lines().collect();
104
105    // Pre-allocate with estimated capacity: roughly 1 hunk per 4 diff lines
106    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
150/// Extracts the line number from a `git diff` hunk header line.
151///
152/// # Errors
153/// - If the hunk header line lacks sufficient space-separated parts.
154/// - If the newline number part is malformed (missing comma).
155/// - If the extracted line number value cannot be parsed as a valid [`usize`].
156fn 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}