git_rune/git/
diff.rs

1use super::repo::GitRepo;
2use crate::{error::Result, utils::GlobFilters};
3use git2::{Diff, DiffOptions};
4
5struct DiffBuilder<'a> {
6    repo: &'a GitRepo,
7    filter: GlobFilters,
8}
9
10struct FilteredDiff<'a> {
11    diff: Diff<'a>,
12    filter: GlobFilters,
13}
14
15impl<'a> DiffBuilder<'a> {
16    fn new(repo: &'a GitRepo, exclude_patterns: Option<&str>) -> Result<Self> {
17        // Convert exclude patterns to GlobFilters format
18        // Default include all files (**/*) and exclude specified patterns
19        let filter = GlobFilters::new(None, exclude_patterns)?;
20
21        Ok(Self { repo, filter })
22    }
23
24    fn build_options() -> DiffOptions {
25        let mut opts = DiffOptions::new();
26        opts.include_untracked(true)
27            .recurse_untracked_dirs(true)
28            .show_untracked_content(true)
29            .include_ignored(false)
30            .patience(true)
31            .minimal(true);
32        opts
33    }
34
35    fn build(self) -> Result<FilteredDiff<'a>> {
36        let index = self.repo.repo.index()?;
37        let head_tree = self
38            .repo
39            .repo
40            .head()
41            .ok()
42            .and_then(|head| head.peel_to_tree().ok());
43
44        let mut opts = Self::build_options();
45        let diff =
46            self.repo
47                .repo
48                .diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut opts))?;
49
50        Ok(FilteredDiff {
51            diff,
52            filter: self.filter,
53        })
54    }
55}
56
57impl<'a> FilteredDiff<'a> {
58    fn get_filtered_paths(&self) -> Result<Vec<String>> {
59        let mut paths = Vec::new();
60
61        self.diff.foreach(
62            &mut |delta, _| {
63                if let Some(path) = delta.new_file().path() {
64                    if let Some(path_str) = path.to_str() {
65                        if self.filter.should_include(path_str) {
66                            paths.push(path_str.to_owned());
67                        }
68                    }
69                }
70                true
71            },
72            None,
73            None,
74            None,
75        )?;
76
77        paths.sort();
78        Ok(paths)
79    }
80
81    fn to_string(&self) -> Result<String> {
82        let mut diff_string = String::new();
83
84        // First, get all valid paths
85        let valid_paths = self.get_filtered_paths()?;
86        let valid_paths: std::collections::HashSet<_> = valid_paths.into_iter().collect();
87
88        self.diff
89            .print(git2::DiffFormat::Patch, |delta, _hunk, line| {
90                if let Some(path) = delta.new_file().path() {
91                    if let Some(path_str) = path.to_str() {
92                        // Only include content from files that pass our filter
93                        if valid_paths.contains(path_str) {
94                            if let Ok(c) = std::str::from_utf8(line.content()) {
95                                diff_string.push_str(c);
96                            }
97                        }
98                    }
99                }
100                true
101            })?;
102
103        Ok(diff_string)
104    }
105}
106
107pub fn get_diff_with_excludes(exclude_patterns: Option<&str>) -> Result<String> {
108    let repo = GitRepo::open()?;
109
110    if !repo.has_staged_changes()? {
111        println!("No staged changes detected");
112        return Ok(String::new());
113    }
114
115    let builder = DiffBuilder::new(&repo, exclude_patterns)?;
116    let filtered_diff = builder.build()?;
117    let diff_string = filtered_diff.to_string()?;
118
119    if diff_string.trim().is_empty() {
120        println!("No changes to commit");
121        Ok(String::new())
122    } else {
123        Ok(diff_string)
124    }
125}
126
127pub fn get_diff() -> Result<String> {
128    get_diff_with_excludes(None)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::fs;
135    use tempfile::TempDir;
136
137    fn setup_test_repo() -> Result<(GitRepo, TempDir)> {
138        let temp = TempDir::new()?;
139        let path = temp.path();
140        let git_repo = git2::Repository::init(path)?;
141
142        // Set up git config
143        let mut config = git_repo.config()?;
144        config.set_str("user.name", "Test")?;
145        config.set_str("user.email", "test@example.com")?;
146
147        // Create test files with distinct content
148        let files = [
149            ("include.txt", "include file content"),
150            ("exclude.js", "javascript content"),
151            ("generated/exclude.txt", "generated content"),
152        ];
153
154        for (file, content) in &files {
155            if let Some(parent) = std::path::Path::new(file).parent() {
156                fs::create_dir_all(path.join(parent))?;
157            }
158            fs::write(path.join(file), content)?;
159        }
160
161        // Initial commit
162        let mut index = git_repo.index()?;
163        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
164        index.write()?;
165
166        let tree_id = index.write_tree()?;
167        let tree = git_repo.find_tree(tree_id)?;
168        let sig = git2::Signature::now("Test", "test@example.com")?;
169        git_repo.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])?;
170
171        // Modify files with new content
172        let modified_files = [
173            ("include.txt", "modified include content"),
174            ("exclude.js", "modified javascript content"),
175            ("generated/exclude.txt", "modified generated content"),
176        ];
177
178        for (file, content) in &modified_files {
179            fs::write(path.join(file), content)?;
180        }
181
182        // Stage changes
183        let mut index = git_repo.index()?;
184        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
185        index.write()?;
186
187        Ok((GitRepo::open_from(path)?, temp))
188    }
189
190    #[test]
191    fn test_filtered_diff() -> Result<()> {
192        let (repo, _temp) = setup_test_repo()?;
193
194        // Test with excludes
195        let builder = DiffBuilder::new(&repo, Some("*.js,generated/*"))?;
196        let filtered_diff = builder.build()?;
197
198        // Test file paths
199        let paths = filtered_diff.get_filtered_paths()?;
200        assert_eq!(
201            paths,
202            vec!["include.txt".to_string()],
203            "Wrong paths were included"
204        );
205
206        // Test diff content
207        let diff_content = filtered_diff.to_string()?;
208        println!("Filtered diff content:\n{}", diff_content);
209
210        // Check diff headers
211        assert!(
212            !diff_content.contains("diff --git a/exclude.js"),
213            "Found excluded JS file header"
214        );
215        assert!(
216            !diff_content.contains("diff --git a/generated/"),
217            "Found excluded generated file header"
218        );
219        assert!(
220            diff_content.contains("diff --git a/include.txt"),
221            "Missing included file header"
222        );
223
224        // Check file content
225        assert!(
226            diff_content.contains("include file content"),
227            "Missing original content from included file"
228        );
229        assert!(
230            diff_content.contains("modified include content"),
231            "Missing modified content from included file"
232        );
233
234        // Verify excluded content is not present
235        assert!(
236            !diff_content.contains("javascript content"),
237            "Found content from excluded JS file"
238        );
239        assert!(
240            !diff_content.contains("generated content"),
241            "Found content from excluded generated file"
242        );
243
244        // Test without excludes
245        let builder = DiffBuilder::new(&repo, None)?;
246        let filtered_diff = builder.build()?;
247        let unfiltered_content = filtered_diff.to_string()?;
248        println!("Unfiltered diff content:\n{}", unfiltered_content);
249
250        // Verify all content is present when unfiltered
251        assert!(unfiltered_content.contains("diff --git a/exclude.js"));
252        assert!(unfiltered_content.contains("javascript content"));
253        assert!(unfiltered_content.contains("diff --git a/generated/"));
254        assert!(unfiltered_content.contains("generated content"));
255        assert!(unfiltered_content.contains("diff --git a/include.txt"));
256        assert!(unfiltered_content.contains("include file content"));
257
258        Ok(())
259    }
260}