1use std::path::Path;
9use std::process::Command;
10use thiserror::Error;
11
12pub struct GitRepo {
14 path: String,
15}
16
17#[derive(Debug, Clone)]
19pub struct Commit {
20 pub hash: String,
21 pub short_hash: String,
22 pub author: String,
23 pub email: String,
24 pub date: String,
25 pub message: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct ChangedFile {
31 pub path: String,
33 pub old_path: Option<String>,
35 pub status: FileStatus,
36 pub additions: u32,
37 pub deletions: u32,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FileStatus {
43 Added,
44 Modified,
45 Deleted,
46 Renamed,
47 Copied,
48 Unknown,
49}
50
51impl FileStatus {
52 fn from_char(c: char) -> Self {
53 match c {
54 'A' => Self::Added,
55 'M' => Self::Modified,
56 'D' => Self::Deleted,
57 'R' => Self::Renamed,
58 'C' => Self::Copied,
59 _ => Self::Unknown,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct BlameLine {
67 pub commit: String,
68 pub author: String,
69 pub date: String,
70 pub line_number: u32,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DiffLineType {
76 Add,
78 Remove,
80 Context,
82}
83
84impl DiffLineType {
85 pub fn as_str(&self) -> &'static str {
87 match self {
88 Self::Add => "add",
89 Self::Remove => "remove",
90 Self::Context => "context",
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct DiffLine {
98 pub change_type: DiffLineType,
100 pub old_line: Option<u32>,
102 pub new_line: Option<u32>,
104 pub content: String,
106}
107
108#[derive(Debug, Clone)]
110pub struct DiffHunk {
111 pub file: String,
113 pub old_start: u32,
115 pub old_count: u32,
117 pub new_start: u32,
119 pub new_count: u32,
121 pub header: String,
123 pub lines: Vec<DiffLine>,
125}
126
127#[derive(Debug, Error)]
129pub enum GitError {
130 #[error("Not a git repository")]
131 NotAGitRepo,
132 #[error("Git command failed: {0}")]
133 CommandFailed(String),
134 #[error("Parse error: {0}")]
135 ParseError(String),
136}
137
138impl GitRepo {
139 pub fn open(path: &Path) -> Result<Self, GitError> {
141 let git_dir = path.join(".git");
142 if !git_dir.exists() {
143 return Err(GitError::NotAGitRepo);
144 }
145
146 Ok(Self { path: path.to_string_lossy().to_string() })
147 }
148
149 pub fn is_git_repo(path: &Path) -> bool {
151 path.join(".git").exists()
152 }
153
154 pub fn current_branch(&self) -> Result<String, GitError> {
156 let output = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
157 Ok(output.trim().to_owned())
158 }
159
160 pub fn current_commit(&self) -> Result<String, GitError> {
162 let output = self.run_git(&["rev-parse", "HEAD"])?;
163 Ok(output.trim().to_owned())
164 }
165
166 pub fn short_hash(&self, commit: &str) -> Result<String, GitError> {
168 let output = self.run_git(&["rev-parse", "--short", commit])?;
169 Ok(output.trim().to_owned())
170 }
171
172 pub fn diff_files(&self, from: &str, to: &str) -> Result<Vec<ChangedFile>, GitError> {
174 let status_output = self.run_git(&["diff", "--name-status", from, to])?;
176
177 let numstat_output = self.run_git(&["diff", "--numstat", from, to])?;
179
180 let mut stats: std::collections::HashMap<String, (u32, u32)> =
182 std::collections::HashMap::new();
183 for line in numstat_output.lines() {
184 if line.is_empty() {
185 continue;
186 }
187 let parts: Vec<&str> = line.split('\t').collect();
188 if parts.len() >= 3 {
189 let add = parts[0].parse::<u32>().unwrap_or(0);
192 let del = parts[1].parse::<u32>().unwrap_or(0);
193 let path = parts[2..].join("\t");
194 stats.insert(path, (add, del));
195 }
196 }
197
198 let mut files = Vec::new();
199
200 for line in status_output.lines() {
202 if line.is_empty() {
203 continue;
204 }
205
206 let parts: Vec<&str> = line.split('\t').collect();
207 if parts.is_empty() {
208 continue;
209 }
210
211 let status_str = parts[0];
212 let first_char = status_str.chars().next().unwrap_or(' ');
213 let status = FileStatus::from_char(first_char);
214
215 let (path, old_path) = if (first_char == 'R' || first_char == 'C') && parts.len() >= 3 {
217 (parts[2].to_owned(), Some(parts[1].to_owned()))
219 } else if parts.len() >= 2 {
220 (parts[1].to_owned(), None)
222 } else {
223 continue;
224 };
225
226 let (additions, deletions) = stats.get(&path).copied().unwrap_or((0, 0));
228
229 files.push(ChangedFile { path, old_path, status, additions, deletions });
230 }
231
232 Ok(files)
233 }
234
235 pub fn status(&self) -> Result<Vec<ChangedFile>, GitError> {
240 let output = self.run_git(&["status", "--porcelain"])?;
241
242 let mut files = Vec::new();
243
244 for line in output.lines() {
245 if line.len() < 3 {
246 continue;
247 }
248
249 let staged_char = line.chars().next().unwrap_or(' ');
252 let unstaged_char = line.chars().nth(1).unwrap_or(' ');
253 let path_part = &line[3..];
254
255 let (status, status_char) = if staged_char != ' ' && staged_char != '?' {
257 (
259 match staged_char {
260 'A' => FileStatus::Added,
261 'M' => FileStatus::Modified,
262 'D' => FileStatus::Deleted,
263 'R' => FileStatus::Renamed,
264 'C' => FileStatus::Copied,
265 _ => FileStatus::Unknown,
266 },
267 staged_char,
268 )
269 } else {
270 (
272 match unstaged_char {
273 '?' | 'A' => FileStatus::Added,
274 'M' => FileStatus::Modified,
275 'D' => FileStatus::Deleted,
276 'R' => FileStatus::Renamed,
277 _ => FileStatus::Unknown,
278 },
279 unstaged_char,
280 )
281 };
282
283 let (path, old_path) = if status_char == 'R' || status_char == 'C' {
285 if let Some(arrow_pos) = path_part.find(" -> ") {
286 let old = path_part[..arrow_pos].to_owned();
287 let new = path_part[arrow_pos + 4..].to_owned();
288 (new, Some(old))
289 } else {
290 (path_part.to_owned(), None)
291 }
292 } else {
293 (path_part.to_owned(), None)
294 };
295
296 files.push(ChangedFile { path, old_path, status, additions: 0, deletions: 0 });
297 }
298
299 Ok(files)
300 }
301
302 pub fn log(&self, count: usize) -> Result<Vec<Commit>, GitError> {
304 let output = self.run_git(&[
305 "log",
306 &format!("-{}", count),
307 "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
308 "--date=short",
309 ])?;
310
311 let mut commits = Vec::new();
312 let mut lines = output.lines().peekable();
313
314 while lines.peek().is_some() {
315 let hash = lines.next().unwrap_or("").to_owned();
316 if hash.is_empty() {
317 continue;
318 }
319
320 let short_hash = lines.next().unwrap_or("").to_owned();
321 let author = lines.next().unwrap_or("").to_owned();
322 let email = lines.next().unwrap_or("").to_owned();
323 let date = lines.next().unwrap_or("").to_owned();
324 let message = lines.next().unwrap_or("").to_owned();
325
326 while lines.peek().map(|l| *l != "---COMMIT---").unwrap_or(false) {
328 lines.next();
329 }
330 lines.next(); commits.push(Commit { hash, short_hash, author, email, date, message });
333 }
334
335 Ok(commits)
336 }
337
338 pub fn file_log(&self, path: &str, count: usize) -> Result<Vec<Commit>, GitError> {
340 let output = self.run_git(&[
341 "log",
342 &format!("-{}", count),
343 "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
344 "--date=short",
345 "--follow",
346 "--",
347 path,
348 ])?;
349
350 let mut commits = Vec::new();
351 let commit_blocks: Vec<&str> = output.split("---COMMIT---").collect();
352
353 for block in commit_blocks {
354 let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect();
355 if lines.len() < 6 {
356 continue;
357 }
358
359 commits.push(Commit {
360 hash: lines[0].to_owned(),
361 short_hash: lines[1].to_owned(),
362 author: lines[2].to_owned(),
363 email: lines[3].to_owned(),
364 date: lines[4].to_owned(),
365 message: lines[5].to_owned(),
366 });
367 }
368
369 Ok(commits)
370 }
371
372 pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>, GitError> {
374 let output = self.run_git(&["blame", "--porcelain", path])?;
375
376 let mut lines = Vec::new();
377 let mut current_commit = String::new();
378 let mut current_author = String::new();
379 let mut current_date = String::new();
380 let mut line_number = 0u32;
381
382 for line in output.lines() {
383 if line.starts_with('\t') {
384 lines.push(BlameLine {
386 commit: current_commit.clone(),
387 author: current_author.clone(),
388 date: current_date.clone(),
389 line_number,
390 });
391 } else if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
392 let parts: Vec<&str> = line.split_whitespace().collect();
394 if !parts.is_empty() {
395 current_commit = parts[0][..8.min(parts[0].len())].to_string();
396 if parts.len() >= 3 {
397 line_number = parts[2].parse().unwrap_or(0);
398 }
399 }
400 } else if let Some(author) = line.strip_prefix("author ") {
401 current_author = author.to_owned();
402 } else if let Some(time) = line.strip_prefix("author-time ") {
403 if let Ok(ts) = time.parse::<i64>() {
405 current_date = format_timestamp(ts);
406 }
407 }
408 }
409
410 Ok(lines)
411 }
412
413 pub fn ls_files(&self) -> Result<Vec<String>, GitError> {
415 let output = self.run_git(&["ls-files"])?;
416 Ok(output.lines().map(String::from).collect())
417 }
418
419 pub fn diff_content(&self, from: &str, to: &str, path: &str) -> Result<String, GitError> {
421 self.run_git(&["diff", from, to, "--", path])
422 }
423
424 pub fn uncommitted_diff(&self, path: &str) -> Result<String, GitError> {
427 self.run_git(&["diff", "HEAD", "--", path])
429 }
430
431 pub fn all_uncommitted_diffs(&self) -> Result<String, GitError> {
434 self.run_git(&["diff", "HEAD"])
435 }
436
437 pub fn has_changes(&self, path: &str) -> Result<bool, GitError> {
439 let output = self.run_git(&["status", "--porcelain", "--", path])?;
440 Ok(!output.trim().is_empty())
441 }
442
443 pub fn last_modified_commit(&self, path: &str) -> Result<Commit, GitError> {
445 let commits = self.file_log(path, 1)?;
446 commits
447 .into_iter()
448 .next()
449 .ok_or_else(|| GitError::ParseError("No commits found".to_owned()))
450 }
451
452 pub fn file_change_frequency(&self, path: &str, days: u32) -> Result<u32, GitError> {
454 let output = self.run_git(&[
455 "log",
456 &format!("--since={} days ago", days),
457 "--oneline",
458 "--follow",
459 "--",
460 path,
461 ])?;
462
463 Ok(output.lines().count() as u32)
464 }
465
466 pub fn file_at_ref(&self, path: &str, git_ref: &str) -> Result<String, GitError> {
483 self.run_git(&["show", &format!("{}:{}", git_ref, path)])
484 }
485
486 pub fn diff_hunks(
498 &self,
499 from_ref: &str,
500 to_ref: &str,
501 path: Option<&str>,
502 ) -> Result<Vec<DiffHunk>, GitError> {
503 let output = match path {
504 Some(p) => self.run_git(&["diff", "-U3", from_ref, to_ref, "--", p])?,
505 None => self.run_git(&["diff", "-U3", from_ref, to_ref])?,
506 };
507
508 parse_diff_hunks(&output)
509 }
510
511 pub fn uncommitted_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
519 let output = match path {
520 Some(p) => self.run_git(&["diff", "-U3", "HEAD", "--", p])?,
521 None => self.run_git(&["diff", "-U3", "HEAD"])?,
522 };
523
524 parse_diff_hunks(&output)
525 }
526
527 pub fn staged_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
535 let output = match path {
536 Some(p) => self.run_git(&["diff", "-U3", "--staged", "--", p])?,
537 None => self.run_git(&["diff", "-U3", "--staged"])?,
538 };
539
540 parse_diff_hunks(&output)
541 }
542
543 fn run_git(&self, args: &[&str]) -> Result<String, GitError> {
545 let output = Command::new("git")
546 .current_dir(&self.path)
547 .args(args)
548 .output()
549 .map_err(|e| GitError::CommandFailed(e.to_string()))?;
550
551 if !output.status.success() {
552 let stderr = String::from_utf8_lossy(&output.stderr);
553 return Err(GitError::CommandFailed(stderr.to_string()));
554 }
555
556 String::from_utf8(output.stdout).map_err(|e| GitError::ParseError(e.to_string()))
557 }
558}
559
560fn format_timestamp(ts: i64) -> String {
562 let secs_per_day = 86400;
564 let days_since_epoch = ts / secs_per_day;
565
566 let mut year = 1970;
568 let mut remaining_days = days_since_epoch;
569
570 loop {
571 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
572 if remaining_days < days_in_year {
573 break;
574 }
575 remaining_days -= days_in_year;
576 year += 1;
577 }
578
579 let days_in_months = if is_leap_year(year) {
580 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
581 } else {
582 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
583 };
584
585 let mut month = 1;
586 for days in days_in_months {
587 if remaining_days < days {
588 break;
589 }
590 remaining_days -= days;
591 month += 1;
592 }
593
594 let day = remaining_days + 1;
595
596 format!("{:04}-{:02}-{:02}", year, month, day)
597}
598
599fn is_leap_year(year: i64) -> bool {
600 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
601}
602
603fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>, GitError> {
608 let mut hunks = Vec::new();
609 let mut current_hunk: Option<DiffHunk> = None;
610 let mut current_file = String::new();
611 let mut old_line = 0u32;
612 let mut new_line = 0u32;
613
614 for line in diff_output.lines() {
615 if line.starts_with("diff --git") {
617 if let Some(hunk) = current_hunk.take() {
619 hunks.push(hunk);
620 }
621 current_file = String::new();
622 continue;
623 }
624 if let Some(path) = line.strip_prefix("--- a/") {
626 current_file = path.to_owned();
627 continue;
628 }
629 if let Some(path) = line.strip_prefix("+++ b/") {
631 current_file = path.to_owned();
632 continue;
633 }
634 if line.starts_with("--- /dev/null") || line.starts_with("+++ /dev/null") {
636 continue;
637 }
638
639 if line.starts_with("@@") {
641 if let Some(hunk) = current_hunk.take() {
643 hunks.push(hunk);
644 }
645
646 if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
648 old_line = old_start;
649 new_line = new_start;
650
651 current_hunk = Some(DiffHunk {
652 file: current_file.clone(),
653 old_start,
654 old_count,
655 new_start,
656 new_count,
657 header: line.to_owned(),
658 lines: Vec::new(),
659 });
660 }
661 } else if let Some(ref mut hunk) = current_hunk {
662 if let Some(first_char) = line.chars().next() {
664 let (change_type, content) = match first_char {
665 '+' => (DiffLineType::Add, line[1..].to_owned()),
666 '-' => (DiffLineType::Remove, line[1..].to_owned()),
667 ' ' => (DiffLineType::Context, line[1..].to_owned()),
668 '\\' => continue, _ => continue, };
671
672 let (old_ln, new_ln) = match change_type {
673 DiffLineType::Add => {
674 let nl = new_line;
675 new_line += 1;
676 (None, Some(nl))
677 },
678 DiffLineType::Remove => {
679 let ol = old_line;
680 old_line += 1;
681 (Some(ol), None)
682 },
683 DiffLineType::Context => {
684 let ol = old_line;
685 let nl = new_line;
686 old_line += 1;
687 new_line += 1;
688 (Some(ol), Some(nl))
689 },
690 };
691
692 hunk.lines.push(DiffLine {
693 change_type,
694 old_line: old_ln,
695 new_line: new_ln,
696 content,
697 });
698 }
699 }
700 }
701
702 if let Some(hunk) = current_hunk {
704 hunks.push(hunk);
705 }
706
707 Ok(hunks)
708}
709
710fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
715 let header = header.strip_prefix("@@")?;
717 let end_idx = header.find("@@")?;
718 let range_part = header[..end_idx].trim();
719
720 let parts: Vec<&str> = range_part.split_whitespace().collect();
721 if parts.len() < 2 {
722 return None;
723 }
724
725 let old_part = parts[0].strip_prefix('-')?;
727 let (old_start, old_count) = parse_range(old_part)?;
728
729 let new_part = parts[1].strip_prefix('+')?;
731 let (new_start, new_count) = parse_range(new_part)?;
732
733 Some((old_start, old_count, new_start, new_count))
734}
735
736fn parse_range(range: &str) -> Option<(u32, u32)> {
738 if let Some((start_str, count_str)) = range.split_once(',') {
739 let start = start_str.parse().ok()?;
740 let count = count_str.parse().ok()?;
741 Some((start, count))
742 } else {
743 let start = range.parse().ok()?;
744 Some((start, 1)) }
746}
747
748#[cfg(test)]
749#[allow(clippy::str_to_string)]
750mod tests {
751 use super::*;
752 use std::process::Command;
753 use tempfile::TempDir;
754
755 fn init_test_repo() -> TempDir {
756 let temp = TempDir::new().unwrap();
757
758 Command::new("git")
760 .current_dir(temp.path())
761 .args(["init"])
762 .output()
763 .unwrap();
764
765 Command::new("git")
767 .current_dir(temp.path())
768 .args(["config", "user.email", "test@test.com"])
769 .output()
770 .unwrap();
771
772 Command::new("git")
773 .current_dir(temp.path())
774 .args(["config", "user.name", "Test"])
775 .output()
776 .unwrap();
777
778 std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
780
781 Command::new("git")
782 .current_dir(temp.path())
783 .args(["add", "."])
784 .output()
785 .unwrap();
786
787 Command::new("git")
788 .current_dir(temp.path())
789 .args(["commit", "-m", "Initial commit"])
790 .output()
791 .unwrap();
792
793 temp
794 }
795
796 #[test]
797 fn test_open_repo() {
798 let temp = init_test_repo();
799 let repo = GitRepo::open(temp.path());
800 assert!(repo.is_ok());
801 }
802
803 #[test]
804 fn test_not_a_repo() {
805 let temp = TempDir::new().unwrap();
806 let repo = GitRepo::open(temp.path());
807 assert!(matches!(repo, Err(GitError::NotAGitRepo)));
808 }
809
810 #[test]
811 fn test_current_branch() {
812 let temp = init_test_repo();
813 let repo = GitRepo::open(temp.path()).unwrap();
814 let branch = repo.current_branch().unwrap();
815 assert!(!branch.is_empty());
817 }
818
819 #[test]
820 fn test_log() {
821 let temp = init_test_repo();
822 let repo = GitRepo::open(temp.path()).unwrap();
823 let commits = repo.log(10).unwrap();
824 assert!(!commits.is_empty());
825 assert_eq!(commits[0].message, "Initial commit");
826 }
827
828 #[test]
829 fn test_ls_files() {
830 let temp = init_test_repo();
831 let repo = GitRepo::open(temp.path()).unwrap();
832 let files = repo.ls_files().unwrap();
833 assert!(files.contains(&"test.txt".to_string()));
834 }
835
836 #[test]
837 fn test_format_timestamp() {
838 let ts = 1704067200;
840 let date = format_timestamp(ts);
841 assert_eq!(date, "2024-01-01");
842 }
843
844 #[test]
845 fn test_file_at_ref() {
846 let temp = init_test_repo();
847 let repo = GitRepo::open(temp.path()).unwrap();
848
849 let content = repo.file_at_ref("test.txt", "HEAD").unwrap();
851 assert_eq!(content.trim(), "hello");
852
853 std::fs::write(temp.path().join("test.txt"), "world").unwrap();
855 Command::new("git")
856 .current_dir(temp.path())
857 .args(["add", "."])
858 .output()
859 .unwrap();
860 Command::new("git")
861 .current_dir(temp.path())
862 .args(["commit", "-m", "Update"])
863 .output()
864 .unwrap();
865
866 let new_content = repo.file_at_ref("test.txt", "HEAD").unwrap();
868 assert_eq!(new_content.trim(), "world");
869
870 let old_content = repo.file_at_ref("test.txt", "HEAD~1").unwrap();
872 assert_eq!(old_content.trim(), "hello");
873 }
874
875 #[test]
876 fn test_parse_hunk_header() {
877 let result = parse_hunk_header("@@ -1,5 +1,7 @@ fn main()");
879 assert_eq!(result, Some((1, 5, 1, 7)));
880
881 let result = parse_hunk_header("@@ -1 +1 @@");
883 assert_eq!(result, Some((1, 1, 1, 1)));
884
885 let result = parse_hunk_header("@@ -10,3 +15 @@");
887 assert_eq!(result, Some((10, 3, 15, 1)));
888
889 let result = parse_hunk_header("not a header");
891 assert_eq!(result, None);
892 }
893
894 #[test]
895 fn test_parse_diff_hunks() {
896 let diff = r#"diff --git a/test.txt b/test.txt
897index abc123..def456 100644
898--- a/test.txt
899+++ b/test.txt
900@@ -1,3 +1,4 @@
901 line 1
902-old line 2
903+new line 2
904+added line
905 line 3
906"#;
907
908 let hunks = parse_diff_hunks(diff).unwrap();
909 assert_eq!(hunks.len(), 1);
910
911 let hunk = &hunks[0];
912 assert_eq!(hunk.old_start, 1);
913 assert_eq!(hunk.old_count, 3);
914 assert_eq!(hunk.new_start, 1);
915 assert_eq!(hunk.new_count, 4);
916 assert_eq!(hunk.lines.len(), 5);
917
918 assert_eq!(hunk.lines[0].change_type, DiffLineType::Context);
920 assert_eq!(hunk.lines[1].change_type, DiffLineType::Remove);
921 assert_eq!(hunk.lines[2].change_type, DiffLineType::Add);
922 assert_eq!(hunk.lines[3].change_type, DiffLineType::Add);
923 assert_eq!(hunk.lines[4].change_type, DiffLineType::Context);
924
925 assert_eq!(hunk.lines[0].old_line, Some(1));
927 assert_eq!(hunk.lines[0].new_line, Some(1));
928 assert_eq!(hunk.lines[1].old_line, Some(2));
929 assert_eq!(hunk.lines[1].new_line, None);
930 assert_eq!(hunk.lines[2].old_line, None);
931 assert_eq!(hunk.lines[2].new_line, Some(2));
932 }
933
934 #[test]
935 fn test_diff_hunks() {
936 let temp = init_test_repo();
937 let repo = GitRepo::open(temp.path()).unwrap();
938
939 std::fs::write(temp.path().join("test.txt"), "hello\nworld\n").unwrap();
941 Command::new("git")
942 .current_dir(temp.path())
943 .args(["add", "."])
944 .output()
945 .unwrap();
946 Command::new("git")
947 .current_dir(temp.path())
948 .args(["commit", "-m", "Add world"])
949 .output()
950 .unwrap();
951
952 let hunks = repo.diff_hunks("HEAD~1", "HEAD", Some("test.txt")).unwrap();
954 assert!(!hunks.is_empty());
955
956 let hunk = &hunks[0];
958 assert!(hunk.old_start > 0);
959 assert!(!hunk.header.is_empty());
960 }
961
962 #[test]
963 fn test_uncommitted_hunks() {
964 let temp = init_test_repo();
965 let repo = GitRepo::open(temp.path()).unwrap();
966
967 std::fs::write(temp.path().join("test.txt"), "modified content").unwrap();
969
970 let hunks = repo.uncommitted_hunks(Some("test.txt")).unwrap();
971 assert!(!hunks.is_empty());
972
973 let total_changes: usize = hunks.iter().map(|h| h.lines.len()).sum();
975 assert!(total_changes > 0);
976 }
977}