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 old_start: u32,
113 pub old_count: u32,
115 pub new_start: u32,
117 pub new_count: u32,
119 pub header: String,
121 pub lines: Vec<DiffLine>,
123}
124
125#[derive(Debug, Error)]
127pub enum GitError {
128 #[error("Not a git repository")]
129 NotAGitRepo,
130 #[error("Git command failed: {0}")]
131 CommandFailed(String),
132 #[error("Parse error: {0}")]
133 ParseError(String),
134}
135
136impl GitRepo {
137 pub fn open(path: &Path) -> Result<Self, GitError> {
139 let git_dir = path.join(".git");
140 if !git_dir.exists() {
141 return Err(GitError::NotAGitRepo);
142 }
143
144 Ok(Self { path: path.to_string_lossy().to_string() })
145 }
146
147 pub fn is_git_repo(path: &Path) -> bool {
149 path.join(".git").exists()
150 }
151
152 pub fn current_branch(&self) -> Result<String, GitError> {
154 let output = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
155 Ok(output.trim().to_owned())
156 }
157
158 pub fn current_commit(&self) -> Result<String, GitError> {
160 let output = self.run_git(&["rev-parse", "HEAD"])?;
161 Ok(output.trim().to_owned())
162 }
163
164 pub fn short_hash(&self, commit: &str) -> Result<String, GitError> {
166 let output = self.run_git(&["rev-parse", "--short", commit])?;
167 Ok(output.trim().to_owned())
168 }
169
170 pub fn diff_files(&self, from: &str, to: &str) -> Result<Vec<ChangedFile>, GitError> {
172 let status_output = self.run_git(&["diff", "--name-status", from, to])?;
174
175 let numstat_output = self.run_git(&["diff", "--numstat", from, to])?;
177
178 let mut stats: std::collections::HashMap<String, (u32, u32)> =
180 std::collections::HashMap::new();
181 for line in numstat_output.lines() {
182 if line.is_empty() {
183 continue;
184 }
185 let parts: Vec<&str> = line.split('\t').collect();
186 if parts.len() >= 3 {
187 let add = parts[0].parse::<u32>().unwrap_or(0);
190 let del = parts[1].parse::<u32>().unwrap_or(0);
191 let path = parts[2..].join("\t");
192 stats.insert(path, (add, del));
193 }
194 }
195
196 let mut files = Vec::new();
197
198 for line in status_output.lines() {
200 if line.is_empty() {
201 continue;
202 }
203
204 let parts: Vec<&str> = line.split('\t').collect();
205 if parts.is_empty() {
206 continue;
207 }
208
209 let status_str = parts[0];
210 let first_char = status_str.chars().next().unwrap_or(' ');
211 let status = FileStatus::from_char(first_char);
212
213 let (path, old_path) = if (first_char == 'R' || first_char == 'C') && parts.len() >= 3 {
215 (parts[2].to_owned(), Some(parts[1].to_owned()))
217 } else if parts.len() >= 2 {
218 (parts[1].to_owned(), None)
220 } else {
221 continue;
222 };
223
224 let (additions, deletions) = stats.get(&path).copied().unwrap_or((0, 0));
226
227 files.push(ChangedFile { path, old_path, status, additions, deletions });
228 }
229
230 Ok(files)
231 }
232
233 pub fn status(&self) -> Result<Vec<ChangedFile>, GitError> {
238 let output = self.run_git(&["status", "--porcelain"])?;
239
240 let mut files = Vec::new();
241
242 for line in output.lines() {
243 if line.len() < 3 {
244 continue;
245 }
246
247 let staged_char = line.chars().next().unwrap_or(' ');
250 let unstaged_char = line.chars().nth(1).unwrap_or(' ');
251 let path_part = &line[3..];
252
253 let (status, status_char) = if staged_char != ' ' && staged_char != '?' {
255 (
257 match staged_char {
258 'A' => FileStatus::Added,
259 'M' => FileStatus::Modified,
260 'D' => FileStatus::Deleted,
261 'R' => FileStatus::Renamed,
262 'C' => FileStatus::Copied,
263 _ => FileStatus::Unknown,
264 },
265 staged_char,
266 )
267 } else {
268 (
270 match unstaged_char {
271 '?' | 'A' => FileStatus::Added,
272 'M' => FileStatus::Modified,
273 'D' => FileStatus::Deleted,
274 'R' => FileStatus::Renamed,
275 _ => FileStatus::Unknown,
276 },
277 unstaged_char,
278 )
279 };
280
281 let (path, old_path) = if status_char == 'R' || status_char == 'C' {
283 if let Some(arrow_pos) = path_part.find(" -> ") {
284 let old = path_part[..arrow_pos].to_owned();
285 let new = path_part[arrow_pos + 4..].to_owned();
286 (new, Some(old))
287 } else {
288 (path_part.to_owned(), None)
289 }
290 } else {
291 (path_part.to_owned(), None)
292 };
293
294 files.push(ChangedFile { path, old_path, status, additions: 0, deletions: 0 });
295 }
296
297 Ok(files)
298 }
299
300 pub fn log(&self, count: usize) -> Result<Vec<Commit>, GitError> {
302 let output = self.run_git(&[
303 "log",
304 &format!("-{}", count),
305 "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
306 "--date=short",
307 ])?;
308
309 let mut commits = Vec::new();
310 let mut lines = output.lines().peekable();
311
312 while lines.peek().is_some() {
313 let hash = lines.next().unwrap_or("").to_owned();
314 if hash.is_empty() {
315 continue;
316 }
317
318 let short_hash = lines.next().unwrap_or("").to_owned();
319 let author = lines.next().unwrap_or("").to_owned();
320 let email = lines.next().unwrap_or("").to_owned();
321 let date = lines.next().unwrap_or("").to_owned();
322 let message = lines.next().unwrap_or("").to_owned();
323
324 while lines.peek().map(|l| *l != "---COMMIT---").unwrap_or(false) {
326 lines.next();
327 }
328 lines.next(); commits.push(Commit { hash, short_hash, author, email, date, message });
331 }
332
333 Ok(commits)
334 }
335
336 pub fn file_log(&self, path: &str, count: usize) -> Result<Vec<Commit>, GitError> {
338 let output = self.run_git(&[
339 "log",
340 &format!("-{}", count),
341 "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
342 "--date=short",
343 "--follow",
344 "--",
345 path,
346 ])?;
347
348 let mut commits = Vec::new();
349 let commit_blocks: Vec<&str> = output.split("---COMMIT---").collect();
350
351 for block in commit_blocks {
352 let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect();
353 if lines.len() < 6 {
354 continue;
355 }
356
357 commits.push(Commit {
358 hash: lines[0].to_owned(),
359 short_hash: lines[1].to_owned(),
360 author: lines[2].to_owned(),
361 email: lines[3].to_owned(),
362 date: lines[4].to_owned(),
363 message: lines[5].to_owned(),
364 });
365 }
366
367 Ok(commits)
368 }
369
370 pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>, GitError> {
372 let output = self.run_git(&["blame", "--porcelain", path])?;
373
374 let mut lines = Vec::new();
375 let mut current_commit = String::new();
376 let mut current_author = String::new();
377 let mut current_date = String::new();
378 let mut line_number = 0u32;
379
380 for line in output.lines() {
381 if line.starts_with('\t') {
382 lines.push(BlameLine {
384 commit: current_commit.clone(),
385 author: current_author.clone(),
386 date: current_date.clone(),
387 line_number,
388 });
389 } else if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
390 let parts: Vec<&str> = line.split_whitespace().collect();
392 if !parts.is_empty() {
393 current_commit = parts[0][..8.min(parts[0].len())].to_string();
394 if parts.len() >= 3 {
395 line_number = parts[2].parse().unwrap_or(0);
396 }
397 }
398 } else if let Some(author) = line.strip_prefix("author ") {
399 current_author = author.to_owned();
400 } else if let Some(time) = line.strip_prefix("author-time ") {
401 if let Ok(ts) = time.parse::<i64>() {
403 current_date = format_timestamp(ts);
404 }
405 }
406 }
407
408 Ok(lines)
409 }
410
411 pub fn ls_files(&self) -> Result<Vec<String>, GitError> {
413 let output = self.run_git(&["ls-files"])?;
414 Ok(output.lines().map(String::from).collect())
415 }
416
417 pub fn diff_content(&self, from: &str, to: &str, path: &str) -> Result<String, GitError> {
419 self.run_git(&["diff", from, to, "--", path])
420 }
421
422 pub fn uncommitted_diff(&self, path: &str) -> Result<String, GitError> {
425 self.run_git(&["diff", "HEAD", "--", path])
427 }
428
429 pub fn all_uncommitted_diffs(&self) -> Result<String, GitError> {
432 self.run_git(&["diff", "HEAD"])
433 }
434
435 pub fn has_changes(&self, path: &str) -> Result<bool, GitError> {
437 let output = self.run_git(&["status", "--porcelain", "--", path])?;
438 Ok(!output.trim().is_empty())
439 }
440
441 pub fn last_modified_commit(&self, path: &str) -> Result<Commit, GitError> {
443 let commits = self.file_log(path, 1)?;
444 commits
445 .into_iter()
446 .next()
447 .ok_or_else(|| GitError::ParseError("No commits found".to_owned()))
448 }
449
450 pub fn file_change_frequency(&self, path: &str, days: u32) -> Result<u32, GitError> {
452 let output = self.run_git(&[
453 "log",
454 &format!("--since={} days ago", days),
455 "--oneline",
456 "--follow",
457 "--",
458 path,
459 ])?;
460
461 Ok(output.lines().count() as u32)
462 }
463
464 pub fn file_at_ref(&self, path: &str, git_ref: &str) -> Result<String, GitError> {
481 self.run_git(&["show", &format!("{}:{}", git_ref, path)])
482 }
483
484 pub fn diff_hunks(
496 &self,
497 from_ref: &str,
498 to_ref: &str,
499 path: Option<&str>,
500 ) -> Result<Vec<DiffHunk>, GitError> {
501 let output = match path {
502 Some(p) => self.run_git(&["diff", "-U3", from_ref, to_ref, "--", p])?,
503 None => self.run_git(&["diff", "-U3", from_ref, to_ref])?,
504 };
505
506 parse_diff_hunks(&output)
507 }
508
509 pub fn uncommitted_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
517 let output = match path {
518 Some(p) => self.run_git(&["diff", "-U3", "HEAD", "--", p])?,
519 None => self.run_git(&["diff", "-U3", "HEAD"])?,
520 };
521
522 parse_diff_hunks(&output)
523 }
524
525 pub fn staged_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
533 let output = match path {
534 Some(p) => self.run_git(&["diff", "-U3", "--staged", "--", p])?,
535 None => self.run_git(&["diff", "-U3", "--staged"])?,
536 };
537
538 parse_diff_hunks(&output)
539 }
540
541 fn run_git(&self, args: &[&str]) -> Result<String, GitError> {
543 let output = Command::new("git")
544 .current_dir(&self.path)
545 .args(args)
546 .output()
547 .map_err(|e| GitError::CommandFailed(e.to_string()))?;
548
549 if !output.status.success() {
550 let stderr = String::from_utf8_lossy(&output.stderr);
551 return Err(GitError::CommandFailed(stderr.to_string()));
552 }
553
554 String::from_utf8(output.stdout).map_err(|e| GitError::ParseError(e.to_string()))
555 }
556}
557
558fn format_timestamp(ts: i64) -> String {
560 let secs_per_day = 86400;
562 let days_since_epoch = ts / secs_per_day;
563
564 let mut year = 1970;
566 let mut remaining_days = days_since_epoch;
567
568 loop {
569 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
570 if remaining_days < days_in_year {
571 break;
572 }
573 remaining_days -= days_in_year;
574 year += 1;
575 }
576
577 let days_in_months = if is_leap_year(year) {
578 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
579 } else {
580 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
581 };
582
583 let mut month = 1;
584 for days in days_in_months {
585 if remaining_days < days {
586 break;
587 }
588 remaining_days -= days;
589 month += 1;
590 }
591
592 let day = remaining_days + 1;
593
594 format!("{:04}-{:02}-{:02}", year, month, day)
595}
596
597fn is_leap_year(year: i64) -> bool {
598 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
599}
600
601fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>, GitError> {
606 let mut hunks = Vec::new();
607 let mut current_hunk: Option<DiffHunk> = None;
608 let mut old_line = 0u32;
609 let mut new_line = 0u32;
610
611 for line in diff_output.lines() {
612 if line.starts_with("@@") {
614 if let Some(hunk) = current_hunk.take() {
616 hunks.push(hunk);
617 }
618
619 if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
621 old_line = old_start;
622 new_line = new_start;
623
624 current_hunk = Some(DiffHunk {
625 old_start,
626 old_count,
627 new_start,
628 new_count,
629 header: line.to_owned(),
630 lines: Vec::new(),
631 });
632 }
633 } else if let Some(ref mut hunk) = current_hunk {
634 if let Some(first_char) = line.chars().next() {
636 let (change_type, content) = match first_char {
637 '+' => (DiffLineType::Add, line[1..].to_owned()),
638 '-' => (DiffLineType::Remove, line[1..].to_owned()),
639 ' ' => (DiffLineType::Context, line[1..].to_owned()),
640 '\\' => continue, _ => continue, };
643
644 let (old_ln, new_ln) = match change_type {
645 DiffLineType::Add => {
646 let nl = new_line;
647 new_line += 1;
648 (None, Some(nl))
649 },
650 DiffLineType::Remove => {
651 let ol = old_line;
652 old_line += 1;
653 (Some(ol), None)
654 },
655 DiffLineType::Context => {
656 let ol = old_line;
657 let nl = new_line;
658 old_line += 1;
659 new_line += 1;
660 (Some(ol), Some(nl))
661 },
662 };
663
664 hunk.lines.push(DiffLine {
665 change_type,
666 old_line: old_ln,
667 new_line: new_ln,
668 content,
669 });
670 }
671 }
672 }
673
674 if let Some(hunk) = current_hunk {
676 hunks.push(hunk);
677 }
678
679 Ok(hunks)
680}
681
682fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
687 let header = header.strip_prefix("@@")?;
689 let end_idx = header.find("@@")?;
690 let range_part = header[..end_idx].trim();
691
692 let parts: Vec<&str> = range_part.split_whitespace().collect();
693 if parts.len() < 2 {
694 return None;
695 }
696
697 let old_part = parts[0].strip_prefix('-')?;
699 let (old_start, old_count) = parse_range(old_part)?;
700
701 let new_part = parts[1].strip_prefix('+')?;
703 let (new_start, new_count) = parse_range(new_part)?;
704
705 Some((old_start, old_count, new_start, new_count))
706}
707
708fn parse_range(range: &str) -> Option<(u32, u32)> {
710 if let Some((start_str, count_str)) = range.split_once(',') {
711 let start = start_str.parse().ok()?;
712 let count = count_str.parse().ok()?;
713 Some((start, count))
714 } else {
715 let start = range.parse().ok()?;
716 Some((start, 1)) }
718}
719
720#[cfg(test)]
721#[allow(clippy::str_to_string)]
722mod tests {
723 use super::*;
724 use std::process::Command;
725 use tempfile::TempDir;
726
727 fn init_test_repo() -> TempDir {
728 let temp = TempDir::new().unwrap();
729
730 Command::new("git")
732 .current_dir(temp.path())
733 .args(["init"])
734 .output()
735 .unwrap();
736
737 Command::new("git")
739 .current_dir(temp.path())
740 .args(["config", "user.email", "test@test.com"])
741 .output()
742 .unwrap();
743
744 Command::new("git")
745 .current_dir(temp.path())
746 .args(["config", "user.name", "Test"])
747 .output()
748 .unwrap();
749
750 std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
752
753 Command::new("git")
754 .current_dir(temp.path())
755 .args(["add", "."])
756 .output()
757 .unwrap();
758
759 Command::new("git")
760 .current_dir(temp.path())
761 .args(["commit", "-m", "Initial commit"])
762 .output()
763 .unwrap();
764
765 temp
766 }
767
768 #[test]
769 fn test_open_repo() {
770 let temp = init_test_repo();
771 let repo = GitRepo::open(temp.path());
772 assert!(repo.is_ok());
773 }
774
775 #[test]
776 fn test_not_a_repo() {
777 let temp = TempDir::new().unwrap();
778 let repo = GitRepo::open(temp.path());
779 assert!(matches!(repo, Err(GitError::NotAGitRepo)));
780 }
781
782 #[test]
783 fn test_current_branch() {
784 let temp = init_test_repo();
785 let repo = GitRepo::open(temp.path()).unwrap();
786 let branch = repo.current_branch().unwrap();
787 assert!(!branch.is_empty());
789 }
790
791 #[test]
792 fn test_log() {
793 let temp = init_test_repo();
794 let repo = GitRepo::open(temp.path()).unwrap();
795 let commits = repo.log(10).unwrap();
796 assert!(!commits.is_empty());
797 assert_eq!(commits[0].message, "Initial commit");
798 }
799
800 #[test]
801 fn test_ls_files() {
802 let temp = init_test_repo();
803 let repo = GitRepo::open(temp.path()).unwrap();
804 let files = repo.ls_files().unwrap();
805 assert!(files.contains(&"test.txt".to_string()));
806 }
807
808 #[test]
809 fn test_format_timestamp() {
810 let ts = 1704067200;
812 let date = format_timestamp(ts);
813 assert_eq!(date, "2024-01-01");
814 }
815
816 #[test]
817 fn test_file_at_ref() {
818 let temp = init_test_repo();
819 let repo = GitRepo::open(temp.path()).unwrap();
820
821 let content = repo.file_at_ref("test.txt", "HEAD").unwrap();
823 assert_eq!(content.trim(), "hello");
824
825 std::fs::write(temp.path().join("test.txt"), "world").unwrap();
827 Command::new("git")
828 .current_dir(temp.path())
829 .args(["add", "."])
830 .output()
831 .unwrap();
832 Command::new("git")
833 .current_dir(temp.path())
834 .args(["commit", "-m", "Update"])
835 .output()
836 .unwrap();
837
838 let new_content = repo.file_at_ref("test.txt", "HEAD").unwrap();
840 assert_eq!(new_content.trim(), "world");
841
842 let old_content = repo.file_at_ref("test.txt", "HEAD~1").unwrap();
844 assert_eq!(old_content.trim(), "hello");
845 }
846
847 #[test]
848 fn test_parse_hunk_header() {
849 let result = parse_hunk_header("@@ -1,5 +1,7 @@ fn main()");
851 assert_eq!(result, Some((1, 5, 1, 7)));
852
853 let result = parse_hunk_header("@@ -1 +1 @@");
855 assert_eq!(result, Some((1, 1, 1, 1)));
856
857 let result = parse_hunk_header("@@ -10,3 +15 @@");
859 assert_eq!(result, Some((10, 3, 15, 1)));
860
861 let result = parse_hunk_header("not a header");
863 assert_eq!(result, None);
864 }
865
866 #[test]
867 fn test_parse_diff_hunks() {
868 let diff = r#"diff --git a/test.txt b/test.txt
869index abc123..def456 100644
870--- a/test.txt
871+++ b/test.txt
872@@ -1,3 +1,4 @@
873 line 1
874-old line 2
875+new line 2
876+added line
877 line 3
878"#;
879
880 let hunks = parse_diff_hunks(diff).unwrap();
881 assert_eq!(hunks.len(), 1);
882
883 let hunk = &hunks[0];
884 assert_eq!(hunk.old_start, 1);
885 assert_eq!(hunk.old_count, 3);
886 assert_eq!(hunk.new_start, 1);
887 assert_eq!(hunk.new_count, 4);
888 assert_eq!(hunk.lines.len(), 5);
889
890 assert_eq!(hunk.lines[0].change_type, DiffLineType::Context);
892 assert_eq!(hunk.lines[1].change_type, DiffLineType::Remove);
893 assert_eq!(hunk.lines[2].change_type, DiffLineType::Add);
894 assert_eq!(hunk.lines[3].change_type, DiffLineType::Add);
895 assert_eq!(hunk.lines[4].change_type, DiffLineType::Context);
896
897 assert_eq!(hunk.lines[0].old_line, Some(1));
899 assert_eq!(hunk.lines[0].new_line, Some(1));
900 assert_eq!(hunk.lines[1].old_line, Some(2));
901 assert_eq!(hunk.lines[1].new_line, None);
902 assert_eq!(hunk.lines[2].old_line, None);
903 assert_eq!(hunk.lines[2].new_line, Some(2));
904 }
905
906 #[test]
907 fn test_diff_hunks() {
908 let temp = init_test_repo();
909 let repo = GitRepo::open(temp.path()).unwrap();
910
911 std::fs::write(temp.path().join("test.txt"), "hello\nworld\n").unwrap();
913 Command::new("git")
914 .current_dir(temp.path())
915 .args(["add", "."])
916 .output()
917 .unwrap();
918 Command::new("git")
919 .current_dir(temp.path())
920 .args(["commit", "-m", "Add world"])
921 .output()
922 .unwrap();
923
924 let hunks = repo.diff_hunks("HEAD~1", "HEAD", Some("test.txt")).unwrap();
926 assert!(!hunks.is_empty());
927
928 let hunk = &hunks[0];
930 assert!(hunk.old_start > 0);
931 assert!(!hunk.header.is_empty());
932 }
933
934 #[test]
935 fn test_uncommitted_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"), "modified content").unwrap();
941
942 let hunks = repo.uncommitted_hunks(Some("test.txt")).unwrap();
943 assert!(!hunks.is_empty());
944
945 let total_changes: usize = hunks.iter().map(|h| h.lines.len()).sum();
947 assert!(total_changes > 0);
948 }
949}