1use colored::*;
2use git2::{Error as GitError, Repository, Sort};
3
4pub fn get_recent_commits(repo: &Repository, count: usize) -> Result<String, GitError> {
5 let mut revwalk = repo.revwalk()?;
6 revwalk.set_sorting(Sort::TIME)?;
7 revwalk.push_head()?;
8
9 let mut commit_messages = String::new();
10
11 for (i, oid) in revwalk.take(count).enumerate() {
12 if let Ok(oid) = oid {
13 if let Ok(commit) = repo.find_commit(oid) {
14 commit_messages.push_str(&format!(
15 "[{}] {}\n",
16 i + 1,
17 commit.message().unwrap_or("")
18 ));
19 }
20 }
21 }
22
23 Ok(commit_messages)
24}
25
26pub fn get_staged_changes(
27 repo: &Repository,
28 context_lines: u32,
29 max_lines_per_file: usize,
30 max_line_width: usize,
31) -> Result<String, GitError> {
32 let mut opts = git2::DiffOptions::new();
33 opts.context_lines(context_lines);
34
35 let tree = match repo.head().and_then(|head| head.peel_to_tree()) {
36 Ok(tree) => tree,
37 Err(_) => {
38 repo.treebuilder(None)
40 .and_then(|builder| builder.write())
41 .and_then(|oid| repo.find_tree(oid))
42 .map_err(|e| GitError::from_str(&format!("Failed to create empty tree: {}", e)))?
43 }
44 };
45
46 let diff = repo
47 .diff_tree_to_index(Some(&tree), None, Some(&mut opts))
48 .map_err(|e| GitError::from_str(&format!("Failed to get repository diff: {}", e)))?;
49
50 let mut diff_str = String::new();
51 let mut line_count = 0;
52 let mut truncated = false;
53
54 diff.print(git2::DiffFormat::Patch, |delta, _, line| {
55 let file_path = delta
56 .new_file()
57 .path()
58 .unwrap_or_else(|| std::path::Path::new(""));
59 if file_path.extension().is_some_and(|ext| ext == "lock") {
60 return true; }
62
63 if line_count < max_lines_per_file {
64 match line.origin() {
65 '+' | '-' | ' ' => {
66 diff_str.push(line.origin());
68 let line_content = std::str::from_utf8(line.content()).unwrap_or("binary");
69 if line_content.len() > max_line_width {
70 diff_str.push_str(&line_content[..max_line_width]);
71 diff_str.push_str("...");
72 } else {
73 diff_str.push_str(line_content);
74 }
75 line_count += 1; }
77 _ => {
78 diff_str.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
80 }
81 }
82 } else if !truncated {
83 truncated = true;
84 diff_str.push_str("\n[Note: Diff output truncated to max lines per file.]");
85 }
86 true
87 })
88 .map_err(|e| GitError::from_str(&format!("Failed to format diff: {}", e)))?;
89
90 if diff_str.is_empty() {
91 Err(GitError::from_str("No changes have been staged for commit"))
92 } else {
93 Ok(diff_str)
94 }
95}
96
97fn has_unstaged_changes(repo: &Repository) -> Result<bool, GitError> {
98 let diff = repo.diff_index_to_workdir(None, None)?;
99 Ok(diff.stats()?.files_changed() > 0)
100}
101
102pub fn git_staged_changes(repo: &Repository) -> Result<(), Box<dyn std::error::Error>> {
103 let mut opts = git2::DiffOptions::new();
104 let tree = match repo.head().and_then(|head| head.peel_to_tree()) {
105 Ok(tree) => tree,
106 Err(_) => {
107 repo.treebuilder(None)
109 .and_then(|builder| builder.write())
110 .and_then(|oid| repo.find_tree(oid))
111 .map_err(|e| GitError::from_str(&format!("Failed to create empty tree: {}", e)))?
112 }
113 };
114
115 let diff = repo
116 .diff_tree_to_index(Some(&tree), None, Some(&mut opts))
117 .map_err(|e| GitError::from_str(&format!("Failed to get repository diff: {}", e)))?;
118
119 let stats = diff.stats()?;
120
121 println!("\n{}", "Diff Statistics:".blue().bold());
122
123 let insertions = stats.insertions();
125 let deletions = stats.deletions();
126 println!(
127 "{} files changed, {}(+) insertions, {}(-) deletions",
128 stats.files_changed(),
129 format!("{}", insertions).green(),
130 format!("{}", deletions).red(),
131 );
132
133 let mut format_opts = git2::DiffStatsFormat::empty();
135 format_opts.insert(git2::DiffStatsFormat::FULL);
136 format_opts.insert(git2::DiffStatsFormat::INCLUDE_SUMMARY);
137 let changes_buf = stats.to_buf(format_opts, 80)?;
138
139 let changes_str = String::from_utf8_lossy(&changes_buf);
141 let max_filename_len = changes_str
142 .lines()
143 .filter(|line| line.contains('|'))
144 .map(|line| line.split('|').next().unwrap_or("").trim().len())
145 .max()
146 .unwrap_or(0);
147
148 for line in changes_str.lines() {
150 if line.contains('|') {
151 let parts: Vec<&str> = line.splitn(2, '|').collect();
152 if parts.len() == 2 {
153 let (file, changes) = (parts[0].trim(), parts[1].trim());
154 let count = changes.chars().filter(|&c| c == '+' || c == '-').count();
155
156 let num_count = changes.split_whitespace().next().unwrap_or("0");
158
159 print!(
161 "{:<width$} | {:>3} {:>3} ",
162 file,
163 count,
164 num_count,
165 width = max_filename_len
166 );
167
168 for c in changes.chars().filter(|&c| c == '+' || c == '-') {
170 if c == '+' {
171 print!("{}", c.to_string().green());
172 } else {
173 print!("{}", c.to_string().red());
174 }
175 }
176 println!();
177 }
178 }
179 }
180
181 if has_unstaged_changes(repo)? {
183 println!("\n{}", "Warning:".yellow().bold());
184 println!(
185 "{}",
186 "You have unstaged changes that won't be included in this commit.".yellow()
187 );
188 println!(
189 "{}",
190 "Use 'git add' to stage changes you want to include.".yellow()
191 );
192 }
193
194 Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::fs::File;
201 use std::io::Write;
202 use std::path::Path;
203 use tempfile::TempDir;
204
205 fn setup_test_repo() -> (TempDir, Repository) {
206 let temp_dir = TempDir::new().unwrap();
207 let repo = Repository::init(temp_dir.path()).unwrap();
208
209 let mut config = repo.config().unwrap();
211 config.set_str("user.name", "Test User").unwrap();
212 config.set_str("user.email", "test@example.com").unwrap();
213
214 (temp_dir, repo)
215 }
216
217 fn create_and_stage_file(repo: &Repository, name: &str, content: &str) {
218 let path = repo.workdir().unwrap().join(name);
219 let mut file = File::create(path).unwrap();
220 writeln!(file, "{}", content).unwrap();
221
222 let mut index = repo.index().unwrap();
223 index.add_path(Path::new(name)).unwrap();
224 index.write().unwrap();
225 }
226
227 fn commit_all(repo: &Repository, message: &str) {
228 let mut index = repo.index().unwrap();
229 let tree_id = index.write_tree().unwrap();
230 let tree = repo.find_tree(tree_id).unwrap();
231
232 let sig = repo.signature().unwrap();
233 if let Ok(parent) = repo.head().and_then(|h| h.peel_to_commit()) {
234 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
235 .unwrap();
236 } else {
237 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
238 .unwrap();
239 }
240 }
241
242 #[test]
243 fn test_get_staged_changes_empty_repo() {
244 let (_temp_dir, repo) = setup_test_repo();
245 let result = get_staged_changes(&repo, 0, 100, 300);
246 assert!(result.is_err());
247 assert_eq!(
248 result.unwrap_err().message(),
249 "No changes have been staged for commit"
250 );
251 }
252
253 #[test]
254 fn test_get_staged_changes_new_file() {
255 let (_temp_dir, repo) = setup_test_repo();
256
257 create_and_stage_file(&repo, "test.txt", "Hello, World!");
259
260 let changes = get_staged_changes(&repo, 0, 100, 300).unwrap();
261 assert!(changes.contains("Hello, World!"));
262 }
263
264 #[test]
265 fn test_get_staged_changes_modified_file() {
266 let (_temp_dir, repo) = setup_test_repo();
267
268 create_and_stage_file(&repo, "test.txt", "Initial content");
270 commit_all(&repo, "Initial commit");
271
272 create_and_stage_file(&repo, "test.txt", "Modified content");
274
275 let changes = get_staged_changes(&repo, 0, 100, 300).unwrap();
276 assert!(changes.contains("Initial content"));
277 assert!(changes.contains("Modified content"));
278 }
279
280 #[test]
281 fn test_has_unstaged_changes() {
282 let (_temp_dir, repo) = setup_test_repo();
283
284 assert!(!has_unstaged_changes(&repo).unwrap());
286
287 create_and_stage_file(&repo, "test.txt", "Initial content");
289 commit_all(&repo, "Initial commit");
290
291 let path = repo.workdir().unwrap().join("test.txt");
293 let mut file = File::create(path).unwrap();
294 writeln!(file, "Modified content").unwrap();
295
296 assert!(has_unstaged_changes(&repo).unwrap());
298 }
299
300 #[test]
301 fn test_show_git_diff_with_unstaged_changes() {
302 let (_temp_dir, repo) = setup_test_repo();
303
304 create_and_stage_file(&repo, "staged.txt", "Staged content");
306 commit_all(&repo, "Initial commit");
307
308 let path = repo.workdir().unwrap().join("staged.txt");
310 let mut file = File::create(path).unwrap();
311 writeln!(file, "Modified unstaged content").unwrap();
312
313 create_and_stage_file(&repo, "new-staged.txt", "New staged content");
315
316 let result = git_staged_changes(&repo);
318 assert!(result.is_ok());
319 }
320
321 #[test]
322 fn test_exclude_lock_files_from_diff() {
323 let (_temp_dir, repo) = setup_test_repo();
324
325 create_and_stage_file(&repo, "test.lock", "This is a lock file.");
327
328 create_and_stage_file(&repo, "test.txt", "This is a regular file.");
330
331 let changes = get_staged_changes(&repo, 0, 100, 300).unwrap();
332
333 assert!(!changes.contains("This is a lock file."));
335
336 assert!(changes.contains("This is a regular file."));
338 }
339
340 #[test]
341 fn test_max_lines_per_file_limit() {
342 let (_temp_dir, repo) = setup_test_repo();
343
344 let mut content = String::new();
346 for i in 0..600 {
347 content.push_str(&format!("Line {}\n", i));
348 }
349 create_and_stage_file(&repo, "test.txt", &content);
350
351 let max_lines_per_file = 10;
353 let changes = get_staged_changes(&repo, 0, max_lines_per_file, 300).unwrap();
354
355 assert!(changes.contains("[Note: Diff output truncated to max lines per file.]"));
361 assert!(changes.contains(&format!("+Line {}", max_lines_per_file - 1)));
362 assert!(!changes.contains(&format!("+Line {}", max_lines_per_file)));
363 }
364
365 #[test]
366 fn test_max_line_width() {
367 let (_temp_dir, repo) = setup_test_repo();
368
369 let long_line = "a".repeat(400);
371 create_and_stage_file(&repo, "test.txt", &long_line);
372
373 let max_line_width = 100;
375 let changes = get_staged_changes(&repo, 0, 100, max_line_width).unwrap();
376
377 assert!(changes.contains(&long_line[..max_line_width]));
379 assert!(changes.contains("..."));
380 }
381}