• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MitMaro / git-interactive-rebase-tool / 15323464414

29 May 2025 12:06PM UTC coverage: 97.589% (+0.3%) from 97.262%
15323464414

Pull #972

github

web-flow
Merge e2a1a5a79 into b5887b137
Pull Request #972: Add aarch64 Linux as tested platform

4858 of 4978 relevant lines covered (97.59%)

2.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.55
/src/diff/commit_diff_loader.rs
1
use std::{
2
        fmt::{Debug, Formatter},
3
        path::PathBuf,
4
        sync::{Arc, LazyLock},
5
        time::{Duration, Instant},
6
};
7

8
use git2::{Diff, DiffFindOptions, DiffOptions, Repository};
9
use parking_lot::{Mutex, RwLock};
10

11
use crate::{
12
        diff::{
13
                Commit,
14
                CommitDiff,
15
                CommitDiffLoaderOptions,
16
                Delta,
17
                DiffLine,
18
                FileMode,
19
                FileStatus,
20
                FileStatusBuilder,
21
                Status,
22
                thread::LoadStatus,
23
        },
24
        git::GitError,
25
};
26

27
static UNKNOWN_PATH: LazyLock<PathBuf> = LazyLock::new(|| PathBuf::from("unknown"));
2✔
28

29
pub(crate) trait DiffUpdateHandlerFn: Fn(LoadStatus) -> bool + Sync + Send {}
30

31
impl<FN: Fn(LoadStatus) -> bool + Sync + Send> DiffUpdateHandlerFn for FN {}
32

33
fn create_status_update(quick: bool, processed_files: usize, total_files: usize) -> LoadStatus {
1✔
34
        if quick {
2✔
35
                LoadStatus::QuickDiff(processed_files, total_files)
1✔
36
        }
37
        else {
38
                LoadStatus::Diff(processed_files, total_files)
1✔
39
        }
40
}
41

42
pub(crate) struct CommitDiffLoader {
43
        config: CommitDiffLoaderOptions,
44
        repository: Repository,
45
        commit_diff: Arc<RwLock<CommitDiff>>,
46
}
47

48
impl CommitDiffLoader {
49
        pub(crate) fn new(repository: Repository, config: CommitDiffLoaderOptions) -> Self {
1✔
50
                Self {
51
                        repository,
52
                        config,
53
                        commit_diff: Arc::new(RwLock::new(CommitDiff::new())),
2✔
54
                }
55
        }
56

57
        pub(crate) fn reset(&mut self) {
1✔
58
                self.commit_diff.write().clear();
1✔
59
        }
60

61
        pub(crate) fn commit_diff(&self) -> Arc<RwLock<CommitDiff>> {
1✔
62
                Arc::clone(&self.commit_diff)
1✔
63
        }
64

65
        fn diff<'repo>(
1✔
66
                repository: &'repo Repository,
67
                config: &CommitDiffLoaderOptions,
68
                commit: &git2::Commit<'_>,
69
                diff_options: &mut DiffOptions,
70
        ) -> Result<Diff<'repo>, GitError> {
71
                _ = diff_options
1✔
72
                        .context_lines(config.context_lines)
1✔
73
                        .ignore_filemode(false)
1✔
74
                        .ignore_whitespace(config.ignore_whitespace)
1✔
75
                        .ignore_whitespace_change(config.ignore_whitespace_change)
1✔
76
                        .ignore_blank_lines(config.ignore_blank_lines)
1✔
77
                        .include_typechange(true)
1✔
78
                        .include_typechange_trees(true)
1✔
79
                        .indent_heuristic(true)
1✔
80
                        .interhunk_lines(config.interhunk_context)
1✔
81
                        .minimal(true);
1✔
82

83
                let commit_tree = commit.tree().map_err(|e| GitError::DiffLoad { cause: e })?;
1✔
84

85
                if let Some(p) = commit.parents().next() {
2✔
86
                        let parent_tree = p.tree().map_err(|e| GitError::DiffLoad { cause: e })?;
2✔
87
                        repository.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(diff_options))
1✔
88
                }
89
                else {
×
90
                        repository.diff_tree_to_tree(None, Some(&commit_tree), Some(diff_options))
2✔
91
                }
92
                .map_err(|e| GitError::DiffLoad { cause: e })
1✔
93
        }
94

95
        pub(crate) fn load_diff(&mut self, hash: &str, update_notifier: impl DiffUpdateHandlerFn) -> Result<(), GitError> {
9✔
96
                let oid = self
20✔
97
                        .repository
×
98
                        .revparse_single(hash)
9✔
99
                        .map_err(|e| GitError::DiffLoad { cause: e })?
12✔
100
                        .id();
101
                let commit = self
16✔
102
                        .repository
×
103
                        .find_commit(oid)
8✔
104
                        .map_err(|e| GitError::DiffLoad { cause: e })?;
8✔
105

106
                {
107
                        // only the first parent matter for things like diffs, the second parent, if it exists,
108
                        // is only used for conflict resolution, and has no use
109
                        let parent = commit.parents().next().map(|c| Commit::from(&c));
32✔
110
                        let mut commit_diff = self.commit_diff.write();
16✔
111
                        commit_diff.reset(Commit::from(&commit), parent);
16✔
112
                        if update_notifier(LoadStatus::New) {
8✔
113
                                return Ok(());
1✔
114
                        }
115
                }
116

117
                // when a diff contains a lot of untracked files, collecting the diff information can take
118
                // upwards of a minute. This performs a quicker diff, that does not detect copies and
119
                // renames against unmodified files.
120
                if self.config.copies {
7✔
121
                        let should_continue = self.collect(
8✔
122
                                &Self::diff(&self.repository, &self.config, &commit, &mut DiffOptions::new())?,
8✔
123
                                &update_notifier,
×
124
                                true,
125
                        )?;
126

127
                        if !should_continue || update_notifier(LoadStatus::CompleteQuickDiff) {
7✔
128
                                return Ok(());
2✔
129
                        }
130
                }
131

132
                let mut diff_options = DiffOptions::new();
5✔
133
                // include_unmodified added to find copies from unmodified files
134
                _ = diff_options.include_unmodified(self.config.copies);
5✔
135
                let mut diff = Self::diff(&self.repository, &self.config, &commit, &mut diff_options)?;
5✔
136

137
                let mut diff_find_options = DiffFindOptions::new();
5✔
138
                _ = diff_find_options
×
139
                        .rename_limit(self.config.rename_limit as usize)
5✔
140
                        .renames(self.config.renames)
5✔
141
                        .renames_from_rewrites(self.config.renames)
5✔
142
                        .rewrites(self.config.renames)
5✔
143
                        .copies(self.config.copies)
5✔
144
                        .copies_from_unmodified(self.config.copies);
5✔
145

146
                diff.find_similar(Some(&mut diff_find_options))
10✔
147
                        .map_err(|e| GitError::DiffLoad { cause: e })?;
5✔
148
                let should_continue = self.collect(&diff, &update_notifier, false)?;
5✔
149

150
                if should_continue {
5✔
151
                        _ = update_notifier(LoadStatus::DiffComplete);
3✔
152
                        return Ok(());
3✔
153
                }
154
                Ok(())
2✔
155
        }
156

157
        pub(crate) fn collect(
7✔
158
                &self,
159
                diff: &Diff<'_>,
160
                update_handler: &impl DiffUpdateHandlerFn,
161
                quick: bool,
162
        ) -> Result<bool, GitError> {
163
                let file_stats_builder = Mutex::new(FileStatusBuilder::new());
7✔
164
                let mut unmodified_file_count: usize = 0;
7✔
165
                let mut change_count: usize = 0;
7✔
166

167
                let stats = diff.stats().map_err(|e| GitError::DiffLoad { cause: e })?;
14✔
168
                let total_files_changed = stats.files_changed();
14✔
169

170
                if update_handler(create_status_update(quick, 0, total_files_changed)) {
7✔
171
                        return Ok(false);
1✔
172
                }
173
                let mut time = Instant::now();
12✔
174

175
                let collect_result = diff.foreach(
6✔
176
                        &mut |diff_delta, _| {
12✔
177
                                change_count += 1;
6✔
178

179
                                #[cfg(test)]
180
                                {
181
                                        // this is needed to test timing in tests, the other option would be to mock
182
                                        // Instant, but that's more effort than is worth the value.
183
                                        // Since this adds 10ms of delay for each delta, each file added to the diff
184
                                        // will add ~10ms of delay.
185
                                        //
186
                                        // this may be flaky, due to the Diff progress being based on time. However,
187
                                        // the added thread sleep during tests should make the diff progress very
188
                                        // stable, as the diff processing can process the files much faster than a
189
                                        // fraction of a millisecond.
190
                                        std::thread::sleep(Duration::from_millis(10));
6✔
191
                                }
192
                                if time.elapsed() > Duration::from_millis(25) {
10✔
193
                                        if update_handler(create_status_update(quick, change_count, total_files_changed)) {
5✔
194
                                                return false;
2✔
195
                                        }
196
                                        time = Instant::now();
4✔
197
                                }
198

199
                                // unmodified files are included for copy detection, so ignore
200
                                if diff_delta.status() == git2::Delta::Unmodified {
6✔
201
                                        unmodified_file_count += 1;
2✔
202
                                        return true;
1✔
203
                                }
204

205
                                let source_file = diff_delta.old_file();
6✔
206
                                let source_file_mode = FileMode::from(source_file.mode());
6✔
207
                                let source_file_path = source_file.path().unwrap_or(UNKNOWN_PATH.as_path());
6✔
208

209
                                let destination_file = diff_delta.new_file();
6✔
210
                                let destination_file_mode = FileMode::from(destination_file.mode());
6✔
211
                                let destination_file_path = destination_file.path().unwrap_or(UNKNOWN_PATH.as_path());
6✔
212

213
                                let mut fsb = file_stats_builder.lock();
6✔
214
                                fsb.add_file_stat(FileStatus::new(
12✔
215
                                        source_file_path,
216
                                        source_file_mode,
217
                                        source_file.is_binary(),
6✔
218
                                        destination_file_path,
219
                                        destination_file_mode,
220
                                        destination_file.is_binary(),
6✔
221
                                        Status::from(diff_delta.status()),
6✔
222
                                ));
223

224
                                true
6✔
225
                        },
226
                        None,
6✔
227
                        Some(&mut |_, diff_hunk| {
12✔
228
                                let mut fsb = file_stats_builder.lock();
6✔
229
                                fsb.add_delta(Delta::from(&diff_hunk));
12✔
230
                                true
231
                        }),
232
                        Some(&mut |_, _, diff_line| {
12✔
233
                                let mut fsb = file_stats_builder.lock();
6✔
234
                                fsb.add_diff_line(DiffLine::from(&diff_line));
12✔
235
                                true
236
                        }),
237
                );
238

239
                // error caused by early return
240
                if collect_result.is_err() {
12✔
241
                        return Ok(false);
2✔
242
                }
243

244
                let mut commit_diff = self.commit_diff.write();
8✔
245

246
                let number_files_changed = total_files_changed - unmodified_file_count;
4✔
247
                let number_insertions = stats.insertions();
8✔
248
                let number_deletions = stats.deletions();
4✔
249

250
                let fsb = file_stats_builder.into_inner();
4✔
251
                commit_diff.update(fsb.build(), number_files_changed, number_insertions, number_deletions);
8✔
252
                Ok(true)
4✔
253
        }
254
}
255

256
impl Debug for CommitDiffLoader {
257
        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
×
258
                f.debug_struct("CommitDiffLoader")
×
259
                        .field(
260
                                "repository",
261
                                &format!("Repository({})", &self.repository.path().display()),
×
262
                        )
263
                        .finish_non_exhaustive()
264
        }
265
}
266

267
#[cfg(all(unix, test))]
268
mod tests {
269
        use std::{
270
                fs::{File, remove_file},
271
                io::Write as _,
272
                os::unix::fs::symlink,
273
        };
274

275
        use git2::Index;
276

277
        use super::*;
278
        use crate::{diff::Origin, test_helpers::with_temp_repository};
279

280
        impl CommitDiffLoader {
281
                fn take_diff(mut self) -> CommitDiff {
1✔
282
                        let diff = std::mem::replace(&mut self.commit_diff, Arc::new(RwLock::new(CommitDiff::new())));
2✔
283
                        Arc::try_unwrap(diff).unwrap().into_inner()
1✔
284
                }
285
        }
286

287
        #[cfg(not(tarpaulin_include))]
288
        fn _format_status(status: &FileStatus) -> String {
289
                let s = match status.status() {
290
                        Status::Added => "Added",
291
                        Status::Deleted => "Deleted",
292
                        Status::Modified => "Modified",
293
                        Status::Renamed => "Renamed",
294
                        Status::Copied => "Copied",
295
                        Status::Typechange => "Typechange",
296
                        Status::Other => "Other",
297
                };
298

299
                format!("Status {s}")
300
        }
301

302
        #[cfg(not(tarpaulin_include))]
303
        fn _format_file_mode(mode: FileMode) -> String {
304
                String::from(match mode {
305
                        FileMode::Normal => "n",
306
                        FileMode::Executable => "x",
307
                        FileMode::Link => "l",
308
                        FileMode::Other => "o",
309
                })
310
        }
311

312
        #[cfg(not(tarpaulin_include))]
313
        fn _format_paths(status: &FileStatus) -> String {
314
                let source_mode = _format_file_mode(status.source_mode());
315
                let source_binary = if status.source_is_binary() { ",b" } else { "" };
316

317
                if status.source_path() == status.destination_path()
318
                        && status.source_mode() == status.destination_mode()
319
                        && status.source_is_binary() == status.destination_is_binary()
320
                {
321
                        format!("{} ({source_mode}{source_binary})", status.source_path().display())
322
                }
323
                else {
324
                        let destination_binary = if status.destination_is_binary() { ",b" } else { "" };
325
                        format!(
326
                                "{} ({source_mode}{source_binary}) > {} ({}{destination_binary})",
327
                                status.source_path().display(),
328
                                status.destination_path().display(),
329
                                _format_file_mode(status.destination_mode()),
330
                        )
331
                }
332
        }
333

334
        #[cfg(not(tarpaulin_include))]
335
        #[expect(clippy::string_slice, reason = "Slice on safe range.")]
336
        fn _format_diff_line(line: &DiffLine) -> String {
337
                let origin = match line.origin() {
338
                        Origin::Addition => "+",
339
                        Origin::Binary => "B",
340
                        Origin::Context => " ",
341
                        Origin::Deletion => "-",
342
                        Origin::Header => "H",
343
                };
344
                if line.end_of_file() && line.line() != "\n" {
345
                        String::from("\\ No newline at end of file")
346
                }
347
                else {
348
                        format!(
349
                                "{origin}{} {}| {}",
350
                                line.old_line_number()
351
                                        .map_or_else(|| String::from(" "), |v| v.to_string()),
352
                                line.new_line_number()
353
                                        .map_or_else(|| String::from(" "), |v| v.to_string()),
354
                                if line.line().ends_with('\n') {
355
                                        &line.line()[..line.line().len() - 1]
356
                                }
357
                                else {
358
                                        line.line()
359
                                },
360
                        )
361
                }
362
        }
363

364
        #[cfg(not(tarpaulin_include))]
365
        fn _assert_commit_diff(diff: &CommitDiff, expected: &[String]) {
366
                let mut actual = vec![];
367
                for status in diff.file_statuses() {
368
                        actual.push(_format_paths(status));
369
                        actual.push(_format_status(status));
370
                        for delta in status.deltas() {
371
                                actual.push(format!(
372
                                        "@@ -{},{} +{},{} @@{}",
373
                                        delta.old_lines_start(),
374
                                        delta.old_number_lines(),
375
                                        delta.new_lines_start(),
376
                                        delta.new_number_lines(),
377
                                        if delta.context().is_empty() {
378
                                                String::new()
379
                                        }
380
                                        else {
381
                                                format!(" {}", delta.context())
382
                                        },
383
                                ));
384
                                for line in delta.lines() {
385
                                        actual.push(_format_diff_line(line));
386
                                }
387
                        }
388
                }
389
                pretty_assertions::assert_eq!(actual, expected);
390
        }
391

392
        macro_rules! assert_commit_diff {
393
                ($diff:expr, $($arg:expr),*) => {
394
                        let expected = vec![$( String::from($arg), )*];
19✔
395
                        _assert_commit_diff($diff, &expected);
30✔
396
                };
397
        }
398

399
        #[cfg(not(tarpaulin_include))]
400
        fn index(repository: &Repository) -> Index {
401
                repository.index().unwrap()
402
        }
403

404
        #[cfg(not(tarpaulin_include))]
405
        fn root_path(repository: &Repository) -> PathBuf {
406
                repository.path().to_path_buf().parent().unwrap().to_path_buf()
407
        }
408

409
        #[cfg(not(tarpaulin_include))]
410
        fn commit_from_ref<'repo>(repository: &'repo Repository, reference: &str) -> git2::Commit<'repo> {
411
                repository.find_reference(reference).unwrap().peel_to_commit().unwrap()
412
        }
413

414
        #[cfg(not(tarpaulin_include))]
415
        fn add_path(repository: &Repository, name: &str) {
416
                index(repository).add_path(PathBuf::from(name).as_path()).unwrap();
417
        }
418

419
        #[cfg(not(tarpaulin_include))]
420
        fn write_normal_file(repository: &Repository, name: &str, contents: &[&str]) {
421
                let file_path = root_path(repository).join(name);
422
                let mut file = File::create(file_path.as_path()).unwrap();
423
                if !contents.is_empty() {
424
                        writeln!(file, "{}", contents.join("\n")).unwrap();
425
                }
426

427
                index(repository).add_path(PathBuf::from(name).as_path()).unwrap();
428
        }
429

430
        #[cfg(not(tarpaulin_include))]
431
        fn remove_path(repository: &Repository, name: &str) {
432
                let file_path = root_path(repository).join(name);
433
                _ = remove_file(file_path.as_path());
434

435
                index(repository).remove_path(PathBuf::from(name).as_path()).unwrap();
436
        }
437

438
        #[cfg(not(tarpaulin_include))]
439
        fn create_commit(repository: &Repository) {
440
                let sig = git2::Signature::new("name", "name@example.com", &git2::Time::new(1_609_459_200, 0)).unwrap();
441
                let tree = repository.find_tree(index(repository).write_tree().unwrap()).unwrap();
442
                let head = commit_from_ref(repository, "refs/heads/main");
443
                _ = repository
444
                        .commit(Some("HEAD"), &sig, &sig, "title", &tree, &[&head])
445
                        .unwrap();
446
        }
447

448
        #[cfg(not(tarpaulin_include))]
449
        fn diff_from_head(repository: Repository, options: CommitDiffLoaderOptions) -> Result<CommitDiffLoader, GitError> {
450
                let commit = commit_from_ref(&repository, "refs/heads/main");
451
                let hash = commit.id().to_string();
452
                drop(commit);
453
                let mut loader = CommitDiffLoader::new(repository, options);
454
                loader.load_diff(hash.as_str(), |_| false)?;
455
                Ok(loader)
456
        }
457

458
        #[cfg(not(tarpaulin_include))]
459
        fn diff_with_notifier(
460
                repository: Repository,
461
                options: CommitDiffLoaderOptions,
462
                update_notifier: impl DiffUpdateHandlerFn,
463
        ) -> Result<CommitDiffLoader, GitError> {
464
                let commit = commit_from_ref(&repository, "refs/heads/main");
465
                let hash = commit.id().to_string();
466
                drop(commit);
467
                let mut loader = CommitDiffLoader::new(repository, options);
468
                loader.load_diff(hash.as_str(), update_notifier)?;
469
                Ok(loader)
470
        }
471

472
        #[test]
473
        fn load_from_hash_commit_no_parents() {
474
                with_temp_repository(|repository| {
475
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
476
                        let diff = loader.take_diff();
477

478
                        assert_eq!(diff.number_files_changed(), 0);
479
                        assert_eq!(diff.number_insertions(), 0);
480
                        assert_eq!(diff.number_deletions(), 0);
481
                });
482
        }
483

484
        #[test]
485
        fn load_from_hash_added_file() {
486
                with_temp_repository(|repository| {
487
                        write_normal_file(&repository, "a", &["line1"]);
488
                        create_commit(&repository);
489
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
490
                        let diff = loader.take_diff();
491

492
                        assert_eq!(diff.number_files_changed(), 1);
493
                        assert_eq!(diff.number_insertions(), 1);
494
                        assert_eq!(diff.number_deletions(), 0);
495
                        assert_commit_diff!(&diff, "a (o) > a (n)", "Status Added", "@@ -0,0 +1,1 @@", "+  1| line1");
496
                });
497
        }
498

499
        #[test]
500
        fn load_from_hash_removed_file() {
501
                with_temp_repository(|repository| {
502
                        write_normal_file(&repository, "a", &["line1"]);
503
                        create_commit(&repository);
504
                        remove_path(&repository, "a");
505
                        create_commit(&repository);
506

507
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
508
                        let diff = loader.take_diff();
509

510
                        assert_eq!(diff.number_files_changed(), 1);
511
                        assert_eq!(diff.number_insertions(), 0);
512
                        assert_eq!(diff.number_deletions(), 1);
513
                        assert_commit_diff!(
514
                                &diff,
515
                                "a (n) > a (o)",
516
                                "Status Deleted",
517
                                "@@ -1,1 +0,0 @@",
518
                                "-1  | line1"
519
                        );
520
                });
521
        }
522

523
        #[test]
524
        fn load_from_hash_modified_file() {
525
                with_temp_repository(|repository| {
526
                        write_normal_file(&repository, "a", &["line1"]);
527
                        create_commit(&repository);
528
                        write_normal_file(&repository, "a", &["line2"]);
529
                        create_commit(&repository);
530

531
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
532
                        let diff = loader.take_diff();
533

534
                        assert_eq!(diff.number_files_changed(), 1);
535
                        assert_eq!(diff.number_insertions(), 1);
536
                        assert_eq!(diff.number_deletions(), 1);
537
                        assert_commit_diff!(
538
                                &diff,
539
                                "a (n)",
540
                                "Status Modified",
541
                                "@@ -1,1 +1,1 @@",
542
                                "-1  | line1",
543
                                "+  1| line2"
544
                        );
545
                });
546
        }
547

548
        #[test]
549
        fn load_from_hash_with_context() {
550
                with_temp_repository(|repository| {
551
                        write_normal_file(&repository, "a", &[
552
                                "line0", "line1", "line2", "line3", "line4", "line5",
553
                        ]);
554
                        create_commit(&repository);
555
                        write_normal_file(&repository, "a", &[
556
                                "line0",
557
                                "line1",
558
                                "line2",
559
                                "line3-m",
560
                                "line4",
561
                                "line5",
562
                        ]);
563
                        create_commit(&repository);
564

565
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().context_lines(2)).unwrap();
566
                        let diff = loader.take_diff();
567

568
                        assert_commit_diff!(
569
                                &diff,
570
                                "a (n)",
571
                                "Status Modified",
572
                                "@@ -2,5 +2,5 @@ line0",
573
                                " 2 2| line1",
574
                                " 3 3| line2",
575
                                "-4  | line3",
576
                                "+  4| line3-m",
577
                                " 5 5| line4",
578
                                " 6 6| line5"
579
                        );
580
                });
581
        }
582

583
        #[test]
584
        fn load_from_hash_ignore_white_space_change() {
585
                with_temp_repository(|repository| {
586
                        write_normal_file(&repository, "a", &[" line0", "line1"]);
587
                        create_commit(&repository);
588
                        write_normal_file(&repository, "a", &["  line0", " line1-m"]);
589
                        create_commit(&repository);
590

591
                        let loader = diff_from_head(
592
                                repository,
593
                                CommitDiffLoaderOptions::new().ignore_whitespace_change(true),
594
                        )
595
                        .unwrap();
596
                        let diff = loader.take_diff();
597

598
                        assert_commit_diff!(
599
                                &diff,
600
                                "a (n)",
601
                                "Status Modified",
602
                                "@@ -2,1 +2,1 @@",
603
                                "-2  | line1",
604
                                "+  2|  line1-m"
605
                        );
606
                });
607
        }
608

609
        #[test]
610
        fn load_from_hash_ignore_white_space() {
611
                with_temp_repository(|repository| {
612
                        write_normal_file(&repository, "a", &["line0", "line1"]);
613
                        create_commit(&repository);
614
                        write_normal_file(&repository, "a", &["  line0", " line1-m"]);
615
                        create_commit(&repository);
616

617
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().ignore_whitespace(true)).unwrap();
618
                        let diff = loader.take_diff();
619

620
                        assert_commit_diff!(
621
                                &diff,
622
                                "a (n)",
623
                                "Status Modified",
624
                                "@@ -2,1 +2,1 @@ line0",
625
                                "-2  | line1",
626
                                "+  2|  line1-m"
627
                        );
628
                });
629
        }
630

631
        #[test]
632
        fn load_from_hash_copies() {
633
                with_temp_repository(|repository| {
634
                        write_normal_file(&repository, "a", &["line0"]);
635
                        create_commit(&repository);
636
                        write_normal_file(&repository, "b", &["line0"]);
637
                        create_commit(&repository);
638

639
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().copies(true)).unwrap();
640
                        let diff = loader.take_diff();
641

642
                        assert_eq!(diff.number_files_changed(), 1);
643
                        assert_eq!(diff.number_insertions(), 0);
644
                        assert_eq!(diff.number_deletions(), 0);
645
                        assert_commit_diff!(&diff, "a (n) > b (n)", "Status Copied");
646
                });
647
        }
648

649
        #[test]
650
        fn load_from_hash_copies_modified_source() {
651
                with_temp_repository(|repository| {
652
                        write_normal_file(&repository, "a", &["line0"]);
653
                        create_commit(&repository);
654
                        write_normal_file(&repository, "a", &["line0", "a"]);
655
                        write_normal_file(&repository, "b", &["line0"]);
656
                        create_commit(&repository);
657

658
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().copies(true)).unwrap();
659
                        let diff = loader.take_diff();
660

661
                        assert_eq!(diff.number_files_changed(), 2);
662
                        assert_eq!(diff.number_insertions(), 1);
663
                        assert_eq!(diff.number_deletions(), 0);
664
                        assert_commit_diff!(
665
                                &diff,
666
                                "a (n)",
667
                                "Status Modified",
668
                                "@@ -1,0 +2,1 @@ line0",
669
                                "+  2| a",
670
                                "a (n) > b (n)",
671
                                "Status Copied"
672
                        );
673
                });
674
        }
675

676
        #[test]
677
        fn load_from_hash_interhunk_context() {
678
                with_temp_repository(|repository| {
679
                        write_normal_file(&repository, "a", &[
680
                                "line0", "line1", "line2", "line3", "line4", "line5",
681
                        ]);
682
                        create_commit(&repository);
683
                        write_normal_file(&repository, "a", &[
684
                                "line0",
685
                                "line1-m",
686
                                "line2",
687
                                "line3",
688
                                "line4-m",
689
                                "line5",
690
                        ]);
691
                        create_commit(&repository);
692

693
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().interhunk_context(2)).unwrap();
694
                        let diff = loader.take_diff();
695

696
                        assert_commit_diff!(
697
                                &diff,
698
                                "a (n)",
699
                                "Status Modified",
700
                                "@@ -2,4 +2,4 @@ line0",
701
                                "-2  | line1",
702
                                "+  2| line1-m",
703
                                " 3 3| line2",
704
                                " 4 4| line3",
705
                                "-5  | line4",
706
                                "+  5| line4-m"
707
                        );
708
                });
709
        }
710

711
        #[test]
712
        fn load_from_hash_rename_source_not_modified() {
713
                with_temp_repository(|repository| {
714
                        write_normal_file(&repository, "a", &["line0"]);
715
                        create_commit(&repository);
716
                        remove_path(&repository, "a");
717
                        write_normal_file(&repository, "b", &["line0"]);
718
                        create_commit(&repository);
719

720
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().renames(true, 100)).unwrap();
721
                        let diff = loader.take_diff();
722

723
                        assert_eq!(diff.number_files_changed(), 1);
724
                        assert_eq!(diff.number_insertions(), 0);
725
                        assert_eq!(diff.number_deletions(), 0);
726
                        assert_commit_diff!(&diff, "a (n) > b (n)", "Status Renamed");
727
                });
728
        }
729

730
        #[test]
731
        fn load_from_hash_rename_source_modified() {
732
                // this test can be confusing to follow, here is how it is created:
733
                // - starting with an existing tracked file "a"
734
                // - move "a" and call it "b"
735
                // - create a new file "a" with different contents
736
                // this creates a situation where git detects the rename from the original unmodified
737
                // version of "a" before a new file called "a" was created
738
                with_temp_repository(|repository| {
739
                        write_normal_file(&repository, "a", &["line0"]);
740
                        create_commit(&repository);
741
                        write_normal_file(&repository, "a", &["other0"]);
742
                        write_normal_file(&repository, "b", &["line0"]);
743
                        create_commit(&repository);
744

745
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().renames(true, 100)).unwrap();
746
                        let diff = loader.take_diff();
747

748
                        assert_eq!(diff.number_files_changed(), 2);
749
                        assert_eq!(diff.number_insertions(), 1);
750
                        assert_eq!(diff.number_deletions(), 0);
751
                        assert_commit_diff!(
752
                                &diff,
753
                                "a (o) > a (n)",
754
                                "Status Added",
755
                                "@@ -0,0 +1,1 @@",
756
                                "+  1| other0",
757
                                "a (n) > b (n)",
758
                                "Status Renamed"
759
                        );
760
                });
761
        }
762

763
        #[cfg(unix)]
764
        #[test]
765
        fn load_from_hash_file_mode_executable() {
766
                with_temp_repository(|repository| {
767
                        use std::os::unix::fs::PermissionsExt as _;
768

769
                        let root = root_path(&repository);
770

771
                        write_normal_file(&repository, "a", &["line0"]);
772
                        create_commit(&repository);
773
                        let file = File::open(root.join("a")).unwrap();
774
                        let mut permissions = file.metadata().unwrap().permissions();
775
                        permissions.set_mode(0o755);
776
                        file.set_permissions(permissions).unwrap();
777

778
                        add_path(&repository, "a");
779
                        create_commit(&repository);
780

781
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new().renames(true, 100)).unwrap();
782
                        let diff = loader.take_diff();
783

784
                        assert_eq!(diff.number_files_changed(), 1);
785
                        assert_eq!(diff.number_insertions(), 0);
786
                        assert_eq!(diff.number_deletions(), 0);
787
                        assert_commit_diff!(&diff, "a (n) > a (x)", "Status Modified");
788
                });
789
        }
790

791
        #[cfg(unix)]
792
        #[test]
793
        fn load_from_hash_type_changed() {
794
                with_temp_repository(|repository| {
795
                        write_normal_file(&repository, "a", &["line0"]);
796
                        write_normal_file(&repository, "b", &["line0"]);
797
                        create_commit(&repository);
798
                        remove_path(&repository, "a");
799
                        let root = root_path(&repository);
800
                        symlink(root.join("b"), root.join("a")).unwrap();
801
                        add_path(&repository, "a");
802
                        add_path(&repository, "b");
803
                        create_commit(&repository);
804

805
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
806
                        let diff = loader.take_diff();
807

808
                        assert_eq!(diff.number_files_changed(), 1);
809
                        assert_eq!(diff.number_insertions(), 0);
810
                        assert_eq!(diff.number_deletions(), 0);
811
                        assert_commit_diff!(&diff, "a (n) > a (l)", "Status Typechange");
812
                });
813
        }
814

815
        #[test]
816
        fn load_from_hash_binary_added_file() {
817
                with_temp_repository(|repository| {
818
                        write_normal_file(&repository, "a", &["line1"]);
819
                        create_commit(&repository);
820

821
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
822
                        let diff = loader.take_diff();
823

824
                        assert_eq!(diff.number_files_changed(), 1);
825
                        assert_eq!(diff.number_insertions(), 1);
826
                        assert_eq!(diff.number_deletions(), 0);
827
                        assert_commit_diff!(&diff, "a (o) > a (n)", "Status Added", "@@ -0,0 +1,1 @@", "+  1| line1");
828
                });
829
        }
830

831
        #[test]
832
        fn load_from_hash_binary_modified_file() {
833
                with_temp_repository(|repository| {
834
                        // treat all files as binary
835
                        write_normal_file(&repository, ".gitattributes", &["a binary"]);
836
                        write_normal_file(&repository, "a", &["line1"]);
837
                        create_commit(&repository);
838
                        write_normal_file(&repository, "a", &["line2"]);
839
                        create_commit(&repository);
840

841
                        let loader = diff_from_head(repository, CommitDiffLoaderOptions::new()).unwrap();
842
                        let diff = loader.take_diff();
843

844
                        assert_eq!(diff.number_files_changed(), 1);
845
                        assert_eq!(diff.number_insertions(), 0);
846
                        assert_eq!(diff.number_deletions(), 0);
847
                        assert_commit_diff!(&diff, "a (n,b)", "Status Modified");
848
                });
849
        }
850

851
        #[test]
852
        fn diff_notifier() {
853
                with_temp_repository(|repository| {
854
                        for i in 0..10 {
855
                                write_normal_file(&repository, format!("a-{i}").as_str(), &["line"]);
856
                        }
857
                        create_commit(&repository);
858

859
                        let calls = Arc::new(Mutex::new(Vec::new()));
860
                        let notifier_calls = Arc::clone(&calls);
861
                        let notifier = move |status| {
862
                                let mut c = notifier_calls.lock();
863
                                c.push(status);
864
                                false
865
                        };
866

867
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new(), notifier).unwrap();
868

869
                        let c = calls.lock();
870
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
871
                        assert_eq!(c.get(1).unwrap(), &LoadStatus::Diff(0, 10));
872
                        assert!(matches!(c.get(2).unwrap(), &LoadStatus::Diff(_, 10)));
873
                        assert!(matches!(c.last().unwrap(), &LoadStatus::DiffComplete));
874
                });
875
        }
876

877
        #[test]
878
        fn diff_notifier_with_copies() {
879
                with_temp_repository(|repository| {
880
                        for i in 0..10 {
881
                                write_normal_file(&repository, format!("a-{i}").as_str(), &["line"]);
882
                        }
883
                        create_commit(&repository);
884

885
                        let calls = Arc::new(Mutex::new(Vec::new()));
886
                        let notifier_calls = Arc::clone(&calls);
887
                        let notifier = move |status| {
888
                                let mut c = notifier_calls.lock();
889
                                c.push(status);
890
                                false
891
                        };
892

893
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new().copies(true), notifier).unwrap();
894

895
                        // Since the exact emitted statues are based on time, this matches a dynamic pattern of:
896
                        //                 - New
897
                        //                 - QuickDiff(0, 10)
898
                        //                 - QuickDiff(>0, 10)
899
                        //                 - CompleteQuickDiff
900
                        //                 - Diff(0, 10)
901
                        //                 - Diff(>0, 10)
902
                        //                 - DiffComplete
903
                        let mut pass = false;
904
                        let mut expected = LoadStatus::New;
905
                        for c in calls.lock().clone() {
906
                                match (&expected, c) {
907
                                        (&LoadStatus::New, LoadStatus::New) => {
908
                                                expected = LoadStatus::QuickDiff(0, 10);
909
                                        },
910
                                        (&LoadStatus::QuickDiff(0, 10), LoadStatus::QuickDiff(0, 10)) => {
911
                                                expected = LoadStatus::QuickDiff(1, 10);
912
                                        },
913
                                        (&LoadStatus::QuickDiff(1, 10), LoadStatus::QuickDiff(p, 10)) => {
914
                                                assert!(p > 0);
915
                                                expected = LoadStatus::CompleteQuickDiff;
916
                                        },
917
                                        (&LoadStatus::CompleteQuickDiff, LoadStatus::CompleteQuickDiff) => {
918
                                                expected = LoadStatus::Diff(0, 10);
919
                                        },
920
                                        (&LoadStatus::Diff(0, 10), LoadStatus::Diff(0, 10)) => {
921
                                                expected = LoadStatus::Diff(1, 10);
922
                                        },
923
                                        (&LoadStatus::Diff(1, 10), LoadStatus::Diff(p, 10)) => {
924
                                                assert!(p > 0);
925
                                                expected = LoadStatus::DiffComplete;
926
                                        },
927
                                        (&LoadStatus::DiffComplete, LoadStatus::DiffComplete) => {
928
                                                pass = true;
929
                                        },
930
                                        (..) => {},
931
                                }
932
                        }
933

934
                        assert!(pass);
935
                });
936
        }
937

938
        #[test]
939
        fn cancel_diff_after_setting_commit() {
940
                with_temp_repository(|repository| {
941
                        write_normal_file(&repository, "a", &["line1"]);
942
                        create_commit(&repository);
943

944
                        let calls = Arc::new(Mutex::new(Vec::new()));
945
                        let notifier_calls = Arc::clone(&calls);
946
                        let notifier = move |status| {
947
                                let mut c = notifier_calls.lock();
948
                                c.push(status);
949
                                true
950
                        };
951

952
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new(), notifier).unwrap();
953

954
                        let c = calls.lock();
955
                        assert_eq!(c.len(), 1);
956
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
957
                });
958
        }
959

960
        #[test]
961
        fn cancel_diff_after_collect_load_stats() {
962
                with_temp_repository(|repository| {
963
                        write_normal_file(&repository, "a", &["line1"]);
964
                        create_commit(&repository);
965

966
                        let calls = Arc::new(Mutex::new(Vec::new()));
967
                        let notifier_calls = Arc::clone(&calls);
968
                        let notifier = move |status| {
969
                                let mut c = notifier_calls.lock();
970
                                c.push(status);
971
                                c.len() == 2
972
                        };
973

974
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new(), notifier).unwrap();
975

976
                        let c = calls.lock();
977
                        assert_eq!(c.len(), 2);
978
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
979
                        assert_eq!(c.get(1).unwrap(), &LoadStatus::Diff(0, 1));
980
                });
981
        }
982

983
        #[test]
984
        fn cancel_diff_during_diff_collect() {
985
                with_temp_repository(|repository| {
986
                        for i in 0..10 {
987
                                write_normal_file(&repository, format!("a-{i}").as_str(), &["line"]);
988
                        }
989
                        create_commit(&repository);
990

991
                        let calls = Arc::new(Mutex::new(Vec::new()));
992
                        let notifier_calls = Arc::clone(&calls);
993
                        let notifier = move |status| {
994
                                let mut c = notifier_calls.lock();
995
                                c.push(status);
996
                                c.len() == 4
997
                        };
998

999
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new(), notifier).unwrap();
1000
                        let c = calls.lock();
1001
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
1002
                        assert_eq!(c.get(1).unwrap(), &LoadStatus::Diff(0, 10));
1003
                        assert!(matches!(c.last().unwrap(), &LoadStatus::Diff(_, 10)));
1004
                });
1005
        }
1006

1007
        #[test]
1008
        fn cancel_diff_during_quick_diff_collect() {
1009
                with_temp_repository(|repository| {
1010
                        for i in 0..10 {
1011
                                write_normal_file(&repository, format!("a-{i}").as_str(), &["line"]);
1012
                        }
1013
                        create_commit(&repository);
1014

1015
                        let calls = Arc::new(Mutex::new(Vec::new()));
1016
                        let notifier_calls = Arc::clone(&calls);
1017
                        let notifier = move |status| {
1018
                                let mut c = notifier_calls.lock();
1019
                                c.push(status);
1020
                                c.len() == 3
1021
                        };
1022

1023
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new().copies(true), notifier).unwrap();
1024
                        let c = calls.lock();
1025
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
1026
                        assert_eq!(c.get(1).unwrap(), &LoadStatus::QuickDiff(0, 10));
1027
                        assert!(matches!(c.last().unwrap(), &LoadStatus::QuickDiff(_, 10)));
1028
                });
1029
        }
1030

1031
        #[test]
1032
        fn cancel_diff_during_quick_diff_complete() {
1033
                with_temp_repository(|repository| {
1034
                        for i in 0..10 {
1035
                                write_normal_file(&repository, format!("a-{i}").as_str(), &["line"]);
1036
                        }
1037
                        create_commit(&repository);
1038

1039
                        let calls = Arc::new(Mutex::new(Vec::new()));
1040
                        let notifier_calls = Arc::clone(&calls);
1041
                        let notifier = move |status| {
1042
                                let mut c = notifier_calls.lock();
1043
                                let rtn = status == LoadStatus::CompleteQuickDiff;
1044
                                c.push(status);
1045
                                rtn
1046
                        };
1047

1048
                        _ = diff_with_notifier(repository, CommitDiffLoaderOptions::new().copies(true), notifier).unwrap();
1049
                        let c = calls.lock();
1050
                        assert_eq!(c.first().unwrap(), &LoadStatus::New);
1051
                        assert_eq!(c.get(1).unwrap(), &LoadStatus::QuickDiff(0, 10));
1052
                        assert!(matches!(c.get(2).unwrap(), &LoadStatus::QuickDiff(_, 10)));
1053
                        assert_eq!(c.last().unwrap(), &LoadStatus::CompleteQuickDiff);
1054
                });
1055
        }
1056
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc