1use std::{cell::RefCell, fs, path::Path, rc::Rc};
4
5use easy_cast::Conv;
6use git2::{Delta, Diff, DiffDelta, DiffFormat, DiffHunk, Patch, Repository};
7use scopetime::scope_time;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 commit_files::{get_commit_diff, get_compare_commits_diff, OldNew},
12 utils::{get_head_repo, work_dir},
13 CommitId, RepoPath,
14};
15use crate::{
16 error::{Error, Result},
17 hash,
18 sync::{get_stashes, repository::repo},
19};
20
21#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
23pub enum DiffLineType {
24 None,
26 Header,
30 Add,
32 Delete,
34}
35
36impl From<git2::DiffLineType> for DiffLineType {
37 fn from(line_type: git2::DiffLineType) -> Self {
38 match line_type {
39 git2::DiffLineType::HunkHeader => Self::Header,
41 git2::DiffLineType::DeleteEOFNL | git2::DiffLineType::Deletion => Self::Delete,
42 git2::DiffLineType::AddEOFNL | git2::DiffLineType::Addition => Self::Add,
43 _ => Self::None,
44 }
45 }
46}
47
48impl Default for DiffLineType {
49 fn default() -> Self {
50 Self::None
51 }
52}
53
54#[derive(Default, Clone, Hash, Debug)]
58pub struct DiffLine {
59 pub content: Box<str>,
61 pub line_type: DiffLineType,
63 pub position: DiffLinePosition,
65}
66
67#[derive(Clone, Copy, Default, Hash, Debug, PartialEq, Eq)]
69pub struct DiffLinePosition {
70 pub old_lineno: Option<u32>,
72 pub new_lineno: Option<u32>,
74}
75
76impl PartialEq<&git2::DiffLine<'_>> for DiffLinePosition {
77 fn eq(&self, other: &&git2::DiffLine) -> bool {
78 other.new_lineno() == self.new_lineno && other.old_lineno() == self.old_lineno
79 }
80}
81
82impl From<&git2::DiffLine<'_>> for DiffLinePosition {
83 fn from(line: &git2::DiffLine<'_>) -> Self {
84 Self {
85 old_lineno: line.old_lineno(),
86 new_lineno: line.new_lineno(),
87 }
88 }
89}
90
91#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
92pub(crate) struct HunkHeader {
93 pub old_start: u32,
94 pub old_lines: u32,
95 pub new_start: u32,
96 pub new_lines: u32,
97}
98
99impl From<DiffHunk<'_>> for HunkHeader {
100 fn from(h: DiffHunk) -> Self {
101 Self {
102 old_start: h.old_start(),
103 old_lines: h.old_lines(),
104 new_start: h.new_start(),
105 new_lines: h.new_lines(),
106 }
107 }
108}
109
110#[derive(Default, Clone, Hash, Debug)]
112pub struct Hunk {
113 pub header_hash: u64,
117 pub lines: Vec<DiffLine>,
119}
120
121#[derive(Default, Clone, Hash, Debug)]
123pub struct FileDiff {
124 pub hunks: Vec<Hunk>,
128 pub lines: usize,
130 pub untracked: bool,
132 pub sizes: (u64, u64),
134 pub size_delta: i64,
136}
137
138#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub struct DiffOptions {
141 pub ignore_whitespace: bool,
143 pub context: u32,
145 pub interhunk_lines: u32,
147}
148
149impl Default for DiffOptions {
150 fn default() -> Self {
151 Self {
152 ignore_whitespace: false,
153 context: 3,
154 interhunk_lines: 0,
155 }
156 }
157}
158
159pub(crate) fn get_diff_raw<'a>(
162 repo: &'a Repository,
163 p: &str, stage: bool,
165 reverse: bool,
166 options: Option<DiffOptions>,
167) -> Result<Diff<'a>> {
168 let mut opt = git2::DiffOptions::new();
171 if let Some(options) = options {
172 opt.context_lines(options.context);
173 opt.ignore_whitespace(options.ignore_whitespace);
174 opt.interhunk_lines(options.interhunk_lines);
175 }
176 opt.pathspec(p);
178 opt.reverse(reverse);
179
180 let diff = if stage {
181 if let Ok(id) = get_head_repo(repo) {
185 let parent = repo.find_commit(id.into())?;
186
187 let tree = parent.tree()?;
188 repo.diff_tree_to_index(Some(&tree), Some(&repo.index()?), Some(&mut opt))?
189 } else {
190 repo.diff_tree_to_index(None, Some(&repo.index()?), Some(&mut opt))?
191 }
192 } else {
193 opt.include_untracked(true);
194 opt.recurse_untracked_dirs(true);
195 repo.diff_index_to_workdir(None, Some(&mut opt))?
196 };
197
198 Ok(diff)
199}
200
201pub fn get_diff(
205 repo_path: &RepoPath,
206 p: &str, stage: bool,
208 options: Option<DiffOptions>,
209) -> Result<FileDiff> {
210 scope_time!("get_diff");
211
212 let repo = repo(repo_path)?;
213 let work_dir = work_dir(&repo)?;
214 let diff = get_diff_raw(&repo, p, stage, false, options)?;
218
219 raw_diff_to_file_diff(&diff, work_dir)
220}
221
222pub fn get_diff_commit(
227 repo_path: &RepoPath,
228 id: CommitId,
229 p: String,
230 options: Option<DiffOptions>,
231) -> Result<FileDiff> {
232 scope_time!("get_diff_commit");
233
234 let repo = repo(repo_path)?;
235 let work_dir = work_dir(&repo)?;
236 let diff = get_commit_diff(
238 &repo,
239 id,
240 Some(p),
241 options,
242 Some(&get_stashes(repo_path)?.into_iter().collect()),
243 )?;
244
245 raw_diff_to_file_diff(&diff, work_dir)
246}
247
248pub fn get_diff_commits(
251 repo_path: &RepoPath,
252 ids: OldNew<CommitId>,
253 p: String,
254 options: Option<DiffOptions>,
255) -> Result<FileDiff> {
256 scope_time!("get_diff_commits");
257
258 let repo = repo(repo_path)?;
259 let work_dir = work_dir(&repo)?;
260 let diff = get_compare_commits_diff(&repo, ids, Some(p), options)?;
261
262 raw_diff_to_file_diff(&diff, work_dir)
263}
264
265#[allow(clippy::too_many_lines)]
269fn raw_diff_to_file_diff(diff: &Diff, work_dir: &Path) -> Result<FileDiff> {
272 let res = Rc::new(RefCell::new(FileDiff::default()));
273 {
274 let mut current_lines = Vec::new();
275 let mut current_hunk: Option<HunkHeader> = None;
276
277 let res_cell = Rc::clone(&res);
278 let adder = move |header: &HunkHeader, lines: &Vec<DiffLine>| {
279 let mut res = res_cell.borrow_mut();
280 res.hunks.push(Hunk {
281 header_hash: hash(header),
282 lines: lines.clone(),
283 });
284 res.lines += lines.len();
285 };
286
287 let res_cell = Rc::clone(&res);
288 let mut put = |delta: DiffDelta, hunk: Option<DiffHunk>, line: git2::DiffLine| {
289 {
290 let mut res = res_cell.borrow_mut();
291 res.sizes = (delta.old_file().size(), delta.new_file().size());
292 res.size_delta = (i64::conv(res.sizes.1)).saturating_sub(i64::conv(res.sizes.0));
294 }
295 if let Some(hunk) = hunk {
296 let hunk_header = HunkHeader::from(hunk);
297
298 match current_hunk {
299 None => current_hunk = Some(hunk_header),
300 Some(h) => {
301 if h != hunk_header {
302 adder(&h, ¤t_lines);
303 current_lines.clear();
304 current_hunk = Some(hunk_header);
305 }
306 }
307 }
308
309 let diff_line = DiffLine {
310 position: DiffLinePosition::from(&line),
311 content: String::from_utf8_lossy(line.content())
312 .trim_matches(is_newline)
315 .into(),
316 line_type: line.origin_value().into(),
317 };
318
319 current_lines.push(diff_line);
320 }
321 };
322
323 let new_file_diff = if diff.deltas().len() == 1 {
324 if let Some(delta) = diff.deltas().next() {
325 if delta.status() == Delta::Untracked {
326 let relative_path = delta.new_file().path().ok_or_else(|| {
327 Error::Generic("new file path is unspecified.".to_string())
328 })?;
329
330 let newfile_path = work_dir.join(relative_path);
331
332 if let Some(newfile_content) = new_file_content(&newfile_path) {
333 let mut patch = Patch::from_buffers(
334 &[],
335 None,
336 newfile_content.as_slice(),
337 Some(&newfile_path),
338 None,
339 )?;
340
341 patch.print(
342 &mut |delta, hunk: Option<DiffHunk>, line: git2::DiffLine| {
343 put(delta, hunk, line);
344 true
345 },
346 )?;
347
348 true
349 } else {
350 false
351 }
352 } else {
353 false
354 }
355 } else {
356 false
357 }
358 } else {
359 false
361 };
362
363 if !new_file_diff {
364 diff.print(
365 DiffFormat::Patch,
366 move |delta, hunk, line: git2::DiffLine| {
367 put(delta, hunk, line);
368 true
369 },
370 )?;
371 }
372
373 if !current_lines.is_empty() {
374 adder(
375 ¤t_hunk.map_or_else(|| Err(Error::Generic("invalid hunk".to_owned())), Ok)?,
376 ¤t_lines,
377 );
378 }
379
380 if new_file_diff {
381 res.borrow_mut().untracked = true;
382 }
383 }
384 let res = Rc::try_unwrap(res).map_err(|_| Error::Generic("rc unwrap error".to_owned()))?;
386 Ok(res.into_inner())
387}
388
389const fn is_newline(c: char) -> bool {
390 c == '\n' || c == '\r'
391}
392
393fn new_file_content(path: &Path) -> Option<Vec<u8>> {
394 if let Ok(meta) = fs::symlink_metadata(path) {
395 if meta.file_type().is_symlink() {
396 if let Ok(path) = fs::read_link(path) {
397 return Some(path.to_str()?.to_string().as_bytes().into());
398 }
399 } else if !meta.file_type().is_dir() {
400 if let Ok(content) = fs::read(path) {
401 return Some(content);
402 }
403 }
404 }
405
406 None
407}
408
409#[cfg(test)]
410mod tests {
411 use std::{
412 fs::{self, File},
413 io::Write,
414 path::Path,
415 };
416
417 use super::{get_diff, get_diff_commit};
418 use crate::{
419 error::Result,
420 sync::{
421 commit, stage_add_file,
422 status::{get_status, StatusType},
423 tests::{get_statuses, repo_init, repo_init_empty},
424 RepoPath,
425 },
426 };
427
428 #[test]
429 fn test_untracked_subfolder() {
430 let (_td, repo) = repo_init().unwrap();
431 let root = repo.path().parent().unwrap();
432 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
433
434 assert_eq!(get_statuses(repo_path), (0, 0));
435
436 fs::create_dir(root.join("foo")).unwrap();
437 File::create(root.join("foo/bar.txt"))
438 .unwrap()
439 .write_all(b"test\nfoo")
440 .unwrap();
441
442 assert_eq!(get_statuses(repo_path), (1, 0));
443
444 let diff = get_diff(repo_path, "foo/bar.txt", false, None).unwrap();
445
446 assert_eq!(diff.hunks.len(), 1);
447 assert_eq!(&*diff.hunks[0].lines[1].content, "test");
448 }
449
450 #[test]
451 fn test_empty_repo() {
452 let file_path = Path::new("foo.txt");
453 let (_td, repo) = repo_init_empty().unwrap();
454 let root = repo.path().parent().unwrap();
455 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
456
457 assert_eq!(get_statuses(repo_path), (0, 0));
458
459 File::create(root.join(file_path))
460 .unwrap()
461 .write_all(b"test\nfoo")
462 .unwrap();
463
464 assert_eq!(get_statuses(repo_path), (1, 0));
465
466 stage_add_file(repo_path, file_path).unwrap();
467
468 assert_eq!(get_statuses(repo_path), (0, 1));
469
470 let diff = get_diff(repo_path, file_path.to_str().unwrap(), true, None).unwrap();
471
472 assert_eq!(diff.hunks.len(), 1);
473 }
474
475 static HUNK_A: &str = r"
4761 start
4772
4783
4794
4805
4816 middle
4827
4838
4849
4850
4861 end";
487
488 static HUNK_B: &str = r"
4891 start
4902 newa
4913
4924
4935
4946 middle
4957
4968
4979
4980 newb
4991 end";
500
501 #[test]
502 fn test_hunks() {
503 let (_td, repo) = repo_init().unwrap();
504 let root = repo.path().parent().unwrap();
505 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
506
507 assert_eq!(get_statuses(repo_path), (0, 0));
508
509 let file_path = root.join("bar.txt");
510
511 {
512 File::create(&file_path)
513 .unwrap()
514 .write_all(HUNK_A.as_bytes())
515 .unwrap();
516 }
517
518 let res = get_status(repo_path, StatusType::WorkingDir, None).unwrap();
519 assert_eq!(res.len(), 1);
520 assert_eq!(res[0].path, "bar.txt");
521
522 stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
523 assert_eq!(get_statuses(repo_path), (0, 1));
524
525 {
527 File::create(&file_path)
528 .unwrap()
529 .write_all(HUNK_B.as_bytes())
530 .unwrap();
531 }
532
533 assert_eq!(get_statuses(repo_path), (1, 1));
534
535 let res = get_diff(repo_path, "bar.txt", false, None).unwrap();
536
537 assert_eq!(res.hunks.len(), 2)
538 }
539
540 #[test]
541 fn test_diff_newfile_in_sub_dir_current_dir() {
542 let file_path = Path::new("foo/foo.txt");
543 let (_td, repo) = repo_init_empty().unwrap();
544 let root = repo.path().parent().unwrap();
545
546 let sub_path = root.join("foo/");
547
548 fs::create_dir_all(&sub_path).unwrap();
549 File::create(root.join(file_path))
550 .unwrap()
551 .write_all(b"test")
552 .unwrap();
553
554 let diff = get_diff(
555 &sub_path.to_str().unwrap().into(),
556 file_path.to_str().unwrap(),
557 false,
558 None,
559 )
560 .unwrap();
561
562 assert_eq!(&*diff.hunks[0].lines[1].content, "test");
563 }
564
565 #[test]
566 fn test_diff_delta_size() -> Result<()> {
567 let file_path = Path::new("bar");
568 let (_td, repo) = repo_init_empty().unwrap();
569 let root = repo.path().parent().unwrap();
570 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
571
572 File::create(root.join(file_path))?.write_all(b"\x00")?;
573
574 stage_add_file(repo_path, file_path).unwrap();
575
576 commit(repo_path, "commit").unwrap();
577
578 File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
579
580 let diff = get_diff(repo_path, file_path.to_str().unwrap(), false, None).unwrap();
581
582 dbg!(&diff);
583 assert_eq!(diff.sizes, (1, 2));
584 assert_eq!(diff.size_delta, 1);
585
586 Ok(())
587 }
588
589 #[test]
590 fn test_binary_diff_delta_size_untracked() -> Result<()> {
591 let file_path = Path::new("bar");
592 let (_td, repo) = repo_init_empty().unwrap();
593 let root = repo.path().parent().unwrap();
594 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
595
596 File::create(root.join(file_path))?.write_all(b"\x00\xc7")?;
597
598 let diff = get_diff(repo_path, file_path.to_str().unwrap(), false, None).unwrap();
599
600 dbg!(&diff);
601 assert_eq!(diff.sizes, (0, 2));
602 assert_eq!(diff.size_delta, 2);
603
604 Ok(())
605 }
606
607 #[test]
608 fn test_diff_delta_size_commit() -> Result<()> {
609 let file_path = Path::new("bar");
610 let (_td, repo) = repo_init_empty().unwrap();
611 let root = repo.path().parent().unwrap();
612 let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
613
614 File::create(root.join(file_path))?.write_all(b"\x00")?;
615
616 stage_add_file(repo_path, file_path).unwrap();
617
618 commit(repo_path, "").unwrap();
619
620 File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
621
622 stage_add_file(repo_path, file_path).unwrap();
623
624 let id = commit(repo_path, "").unwrap();
625
626 let diff = get_diff_commit(repo_path, id, String::new(), None).unwrap();
627
628 dbg!(&diff);
629 assert_eq!(diff.sizes, (1, 2));
630 assert_eq!(diff.size_delta, 1);
631
632 Ok(())
633 }
634}