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 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 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 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 let mut config = git_repo.config()?;
144 config.set_str("user.name", "Test")?;
145 config.set_str("user.email", "test@example.com")?;
146
147 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 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 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 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 let builder = DiffBuilder::new(&repo, Some("*.js,generated/*"))?;
196 let filtered_diff = builder.build()?;
197
198 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 let diff_content = filtered_diff.to_string()?;
208 println!("Filtered diff content:\n{}", diff_content);
209
210 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 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 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 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 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}