cmt/
git.rs

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            // If there's no HEAD (new repo), use an empty tree
39            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; // Skip .lock files
61        }
62
63        if line_count < max_lines_per_file {
64            match line.origin() {
65                '+' | '-' | ' ' => {
66                    // Preserve the prefix character for additions, deletions, and context
67                    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; // Increment line count only for content lines
76                }
77                _ => {
78                    // For headers and other lines, just add the content
79                    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            // If there's no HEAD (new repo), use an empty tree
108            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    // Print the summary with colors
124    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    // Print the per-file changes with visualization
134    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    // Find the longest filename for alignment
140    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    // Print aligned file changes
149    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                // Extract the numeric count from the beginning of changes
157                let num_count = changes.split_whitespace().next().unwrap_or("0");
158
159                // Print the plus/minus visualization with colors
160                print!(
161                    "{:<width$} | {:>3} {:>3} ",
162                    file,
163                    count,
164                    num_count,
165                    width = max_filename_len
166                );
167
168                // Print each character with appropriate color
169                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    // Check for unstaged changes and warn the user
182    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        // Configure test user
210        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 a new file
258        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 commit initial file
269        create_and_stage_file(&repo, "test.txt", "Initial content");
270        commit_all(&repo, "Initial commit");
271
272        // Modify and stage the file
273        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        // Initially should have no unstaged changes
285        assert!(!has_unstaged_changes(&repo).unwrap());
286
287        // Create and stage a file first
288        create_and_stage_file(&repo, "test.txt", "Initial content");
289        commit_all(&repo, "Initial commit");
290
291        // Modify the file without staging it
292        let path = repo.workdir().unwrap().join("test.txt");
293        let mut file = File::create(path).unwrap();
294        writeln!(file, "Modified content").unwrap();
295
296        // Should now detect unstaged changes
297        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 a file
305        create_and_stage_file(&repo, "staged.txt", "Staged content");
306        commit_all(&repo, "Initial commit");
307
308        // Modify the file without staging changes
309        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 another staged file
314        create_and_stage_file(&repo, "new-staged.txt", "New staged content");
315
316        // Should succeed and include warning about unstaged changes
317        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 a .lock file
326        create_and_stage_file(&repo, "test.lock", "This is a lock file.");
327
328        // Create and stage a regular file
329        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 that the .lock file content is not in the diff
334        assert!(!changes.contains("This is a lock file."));
335
336        // Assert that the regular file content is in the diff
337        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        // Create and stage a file with more lines than the max_lines_per_file limit
345        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        // Set max_lines_per_file to 10 for testing
352        let max_lines_per_file = 10;
353        let changes = get_staged_changes(&repo, 0, max_lines_per_file, 300).unwrap();
354
355        // Assert that the diff output does not exceed the max_lines_per_file limit
356        // Allow extra lines for headers and metadata
357        // let allowed_extra_lines = 6; // Adjust this number based on typical header lines
358
359        // Assert that the truncation note is included
360        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        // Create and stage a file with a long line
370        let long_line = "a".repeat(400);
371        create_and_stage_file(&repo, "test.txt", &long_line);
372
373        // Set max_line_width to 100 for testing
374        let max_line_width = 100;
375        let changes = get_staged_changes(&repo, 0, 100, max_line_width).unwrap();
376
377        // Assert that the line is truncated to max_line_width
378        assert!(changes.contains(&long_line[..max_line_width]));
379        assert!(changes.contains("..."));
380    }
381}