gnostr_asyncgit/sync/
diff.rs

1//! sync git api for fetching a diff
2
3use std::{cell::RefCell, fs, path::Path, rc::Rc};
4
5use easy_cast::Conv;
6use git2::{
7	Delta, Diff, DiffDelta, DiffFormat, DiffHunk, Patch, Repository,
8};
9use scopetime::scope_time;
10use serde::{Deserialize, Serialize};
11
12use super::{
13	CommitId, RepoPath,
14	commit_files::{
15		OldNew, get_commit_diff, get_compare_commits_diff,
16	},
17	utils::{get_head_repo, work_dir},
18};
19use crate::{
20	error::{Error, Result},
21	hash,
22	sync::{get_stashes, repository::repo},
23};
24
25/// type of diff of a single line
26#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
27pub enum DiffLineType {
28	/// just surrounding line, no change
29	None,
30	/// header of the hunk
31	/// header of the hunk
32	/// header of the hunk
33	Header,
34	/// line added
35	Add,
36	/// line deleted
37	Delete,
38}
39
40impl From<git2::DiffLineType> for DiffLineType {
41	fn from(line_type: git2::DiffLineType) -> Self {
42		match line_type {
43			//git2::DiffLineType::HunkHeader => Self::Header,
44			git2::DiffLineType::HunkHeader => Self::Header,
45			git2::DiffLineType::DeleteEOFNL
46			| git2::DiffLineType::Deletion => Self::Delete,
47			git2::DiffLineType::AddEOFNL
48			| git2::DiffLineType::Addition => Self::Add,
49			_ => Self::None,
50		}
51	}
52}
53
54impl Default for DiffLineType {
55	fn default() -> Self {
56		Self::None
57	}
58}
59
60///DiffLine
61///DiffLine
62///DiffLine
63#[derive(Default, Clone, Hash, Debug)]
64pub struct DiffLine {
65	///
66	pub content: Box<str>,
67	///
68	pub line_type: DiffLineType,
69	///
70	pub position: DiffLinePosition,
71}
72
73///
74#[derive(Clone, Copy, Default, Hash, Debug, PartialEq, Eq)]
75pub struct DiffLinePosition {
76	///
77	pub old_lineno: Option<u32>,
78	///
79	pub new_lineno: Option<u32>,
80}
81
82impl PartialEq<&git2::DiffLine<'_>> for DiffLinePosition {
83	fn eq(&self, other: &&git2::DiffLine) -> bool {
84		other.new_lineno() == self.new_lineno
85			&& other.old_lineno() == self.old_lineno
86	}
87}
88
89impl From<&git2::DiffLine<'_>> for DiffLinePosition {
90	fn from(line: &git2::DiffLine<'_>) -> Self {
91		Self {
92			old_lineno: line.old_lineno(),
93			new_lineno: line.new_lineno(),
94		}
95	}
96}
97
98#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
99pub(crate) struct HunkHeader {
100	pub old_start: u32,
101	pub old_lines: u32,
102	pub new_start: u32,
103	pub new_lines: u32,
104}
105
106impl From<DiffHunk<'_>> for HunkHeader {
107	fn from(h: DiffHunk) -> Self {
108		Self {
109			old_start: h.old_start(),
110			old_lines: h.old_lines(),
111			new_start: h.new_start(),
112			new_lines: h.new_lines(),
113		}
114	}
115}
116
117/// single diff hunk
118#[derive(Default, Clone, Hash, Debug)]
119pub struct Hunk {
120	/// hash of the hunk header
121	/// hash of the hunk header
122	/// hash of the hunk header
123	pub header_hash: u64,
124	/// list of `DiffLine`s
125	pub lines: Vec<DiffLine>,
126}
127
128/// collection of hunks, sum of all diff lines
129#[derive(Default, Clone, Hash, Debug)]
130pub struct FileDiff {
131	/// list of hunks
132	/// list of hunks
133	/// list of hunks
134	pub hunks: Vec<Hunk>,
135	/// lines total summed up over hunks
136	pub lines: usize,
137	///
138	pub untracked: bool,
139	/// old and new file size in bytes
140	pub sizes: (u64, u64),
141	/// size delta in bytes
142	pub size_delta: i64,
143}
144
145/// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
146#[derive(
147	Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize,
148)]
149pub struct DiffOptions {
150	/// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
151	pub ignore_whitespace: bool,
152	/// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
153	pub context: u32,
154	/// see <https://libgit2.org/libgit2/#HEAD/type/git_diff_options>
155	pub interhunk_lines: u32,
156}
157
158impl Default for DiffOptions {
159	fn default() -> Self {
160		Self {
161			ignore_whitespace: false,
162			context: 3,
163			interhunk_lines: 0,
164		}
165	}
166}
167
168///get_diff_raw
169///make public for event rendering?
170pub(crate) fn get_diff_raw<'a>(
171	repo: &'a Repository,
172	p: &str,//path
173	stage: bool,
174	reverse: bool,
175	options: Option<DiffOptions>,
176) -> Result<Diff<'a>> {
177	// scope_time!("get_diff_raw");
178
179	let mut opt = git2::DiffOptions::new();
180	if let Some(options) = options {
181		opt.context_lines(options.context);
182		opt.ignore_whitespace(options.ignore_whitespace);
183		opt.interhunk_lines(options.interhunk_lines);
184	}
185	//opt.pathspec(p);
186	opt.pathspec(p);
187	opt.reverse(reverse);
188
189	let diff = if stage {
190		// diff against head
191		// diff against head
192		// diff against head
193		if let Ok(id) = get_head_repo(repo) {
194			let parent = repo.find_commit(id.into())?;
195
196			let tree = parent.tree()?;
197			repo.diff_tree_to_index(
198				Some(&tree),
199				Some(&repo.index()?),
200				Some(&mut opt),
201			)?
202		} else {
203			repo.diff_tree_to_index(
204				None,
205				Some(&repo.index()?),
206				Some(&mut opt),
207			)?
208		}
209	} else {
210		opt.include_untracked(true);
211		opt.recurse_untracked_dirs(true);
212		repo.diff_index_to_workdir(None, Some(&mut opt))?
213	};
214
215	Ok(diff)
216}
217
218/// returns diff of a specific file either in `stage` or workdir
219/// returns diff of a specific file either in `stage` or workdir
220/// returns diff of a specific file either in `stage` or workdir
221pub fn get_diff(
222	repo_path: &RepoPath,
223	p: &str,//path
224	stage: bool,
225	options: Option<DiffOptions>,
226) -> Result<FileDiff> {
227	scope_time!("get_diff");
228
229	let repo = repo(repo_path)?;
230	let work_dir = work_dir(&repo)?;
231	//get_diff_raw
232	//get_diff_raw
233	//get_diff_raw
234	let diff = get_diff_raw(&repo, p, stage, false, options)?;
235
236	raw_diff_to_file_diff(&diff, work_dir)
237}
238
239/// returns diff of a specific file inside a commit
240/// returns diff of a specific file inside a commit
241/// returns diff of a specific file inside a commit
242/// see `get_commit_diff`
243pub fn get_diff_commit(
244	repo_path: &RepoPath,
245	id: CommitId,
246	p: String,
247	options: Option<DiffOptions>,
248) -> Result<FileDiff> {
249	scope_time!("get_diff_commit");
250
251	let repo = repo(repo_path)?;
252	let work_dir = work_dir(&repo)?;
253	//get_commit_diff
254	let diff = get_commit_diff(
255		&repo,
256		id,
257		Some(p),
258		options,
259		Some(&get_stashes(repo_path)?.into_iter().collect()),
260	)?;
261
262	raw_diff_to_file_diff(&diff, work_dir)
263}
264
265///for compare
266/// get file changes of a diff between two commits
267pub fn get_diff_commits(
268	repo_path: &RepoPath,
269	ids: OldNew<CommitId>,
270	p: String,
271	options: Option<DiffOptions>,
272) -> Result<FileDiff> {
273	scope_time!("get_diff_commits");
274
275	let repo = repo(repo_path)?;
276	let work_dir = work_dir(&repo)?;
277	let diff =
278		get_compare_commits_diff(&repo, ids, Some(p), options)?;
279
280	raw_diff_to_file_diff(&diff, work_dir)
281}
282
283///
284//TODO: refactor into helper type with the inline closures as
285// dedicated functions
286#[allow(clippy::too_many_lines)]
287//fn raw_diff_to_file_diff(
288//fn raw_diff_to_file_diff(
289fn raw_diff_to_file_diff(
290	diff: &Diff,
291	work_dir: &Path,
292) -> Result<FileDiff> {
293	let res = Rc::new(RefCell::new(FileDiff::default()));
294	{
295		let mut current_lines = Vec::new();
296		let mut current_hunk: Option<HunkHeader> = None;
297
298		let res_cell = Rc::clone(&res);
299		let adder = move |header: &HunkHeader,
300		                  lines: &Vec<DiffLine>| {
301			let mut res = res_cell.borrow_mut();
302			res.hunks.push(Hunk {
303				header_hash: hash(header),
304				lines: lines.clone(),
305			});
306			res.lines += lines.len();
307		};
308
309		let res_cell = Rc::clone(&res);
310		let mut put = |delta: DiffDelta,
311		               hunk: Option<DiffHunk>,
312		               line: git2::DiffLine| {
313			{
314				let mut res = res_cell.borrow_mut();
315				res.sizes = (
316					delta.old_file().size(),
317					delta.new_file().size(),
318				);
319				//TODO: use try_conv
320				res.size_delta = (i64::conv(res.sizes.1))
321					.saturating_sub(i64::conv(res.sizes.0));
322			}
323			if let Some(hunk) = hunk {
324				let hunk_header = HunkHeader::from(hunk);
325
326				match current_hunk {
327					None => current_hunk = Some(hunk_header),
328					Some(h) => {
329						if h != hunk_header {
330							adder(&h, &current_lines);
331							current_lines.clear();
332							current_hunk = Some(hunk_header);
333						}
334					}
335				}
336
337				let diff_line = DiffLine {
338					position: DiffLinePosition::from(&line),
339					content: String::from_utf8_lossy(line.content())
340						//Note: trim await trailing newline
341						// characters
342						.trim_matches(is_newline)
343						.into(),
344					line_type: line.origin_value().into(),
345				};
346
347				current_lines.push(diff_line);
348			}
349		};
350
351		let new_file_diff = if diff.deltas().len() == 1 {
352			if let Some(delta) = diff.deltas().next() {
353				if delta.status() == Delta::Untracked {
354					let relative_path =
355						delta.new_file().path().ok_or_else(|| {
356							Error::Generic(
357								"new file path is unspecified."
358									.to_string(),
359							)
360						})?;
361
362					let newfile_path = work_dir.join(relative_path);
363
364					if let Some(newfile_content) =
365						new_file_content(&newfile_path)
366					{
367						let mut patch = Patch::from_buffers(
368							&[],
369							None,
370							newfile_content.as_slice(),
371							Some(&newfile_path),
372							None,
373						)?;
374
375						patch.print(
376							&mut |delta,
377							      hunk: Option<DiffHunk>,
378							      line: git2::DiffLine| {
379								put(delta, hunk, line);
380								true
381							},
382						)?;
383
384						true
385					} else {
386						false
387					}
388				} else {
389					false
390				}
391			} else {
392				false
393			}
394		} else {
395			//false
396			false
397		};
398
399		if !new_file_diff {
400			diff.print(
401				DiffFormat::Patch,
402				move |delta, hunk, line: git2::DiffLine| {
403					put(delta, hunk, line);
404					true
405				},
406			)?;
407		}
408
409		if !current_lines.is_empty() {
410			adder(
411				&current_hunk.map_or_else(
412					|| Err(Error::Generic("invalid hunk".to_owned())),
413					Ok,
414				)?,
415				&current_lines,
416			);
417		}
418
419		if new_file_diff {
420			res.borrow_mut().untracked = true;
421		}
422	}
423	//
424	let res = Rc::try_unwrap(res)
425		.map_err(|_| Error::Generic("rc unwrap error".to_owned()))?;
426	Ok(res.into_inner())
427}
428
429const fn is_newline(c: char) -> bool {
430	c == '\n' || c == '\r'
431}
432
433fn new_file_content(path: &Path) -> Option<Vec<u8>> {
434	if let Ok(meta) = fs::symlink_metadata(path) {
435		if meta.file_type().is_symlink() {
436			if let Ok(path) = fs::read_link(path) {
437				return Some(
438					path.to_str()?.to_string().as_bytes().into(),
439				);
440			}
441		} else if !meta.file_type().is_dir() {
442			if let Ok(content) = fs::read(path) {
443				return Some(content);
444			}
445		}
446	}
447
448	None
449}
450
451#[cfg(test)]
452mod tests {
453	use std::{
454		fs::{self, File},
455		io::Write,
456		path::Path,
457	};
458
459	use super::{get_diff, get_diff_commit};
460	use crate::{
461		error::Result,
462		sync::{
463			RepoPath, commit, stage_add_file,
464			status::{StatusType, get_status},
465			tests::{get_statuses, repo_init, repo_init_empty},
466		},
467	};
468
469	#[test]
470	fn test_untracked_subfolder() {
471		let (_td, repo) = repo_init().unwrap();
472		let root = repo.path().parent().unwrap();
473		let repo_path: &RepoPath =
474			&root.as_os_str().to_str().unwrap().into();
475
476		assert_eq!(get_statuses(repo_path), (0, 0));
477
478		fs::create_dir(root.join("foo")).unwrap();
479		File::create(root.join("foo/bar.txt"))
480			.unwrap()
481			.write_all(b"test\nfoo")
482			.unwrap();
483
484		assert_eq!(get_statuses(repo_path), (1, 0));
485
486		let diff =
487			get_diff(repo_path, "foo/bar.txt", false, None).unwrap();
488
489		assert_eq!(diff.hunks.len(), 1);
490		assert_eq!(&*diff.hunks[0].lines[1].content, "test");
491	}
492
493	#[test]
494	fn test_empty_repo() {
495		let file_path = Path::new("foo.txt");
496		let (_td, repo) = repo_init_empty().unwrap();
497		let root = repo.path().parent().unwrap();
498		let repo_path: &RepoPath =
499			&root.as_os_str().to_str().unwrap().into();
500
501		assert_eq!(get_statuses(repo_path), (0, 0));
502
503		File::create(root.join(file_path))
504			.unwrap()
505			.write_all(b"test\nfoo")
506			.unwrap();
507
508		assert_eq!(get_statuses(repo_path), (1, 0));
509
510		stage_add_file(repo_path, file_path).unwrap();
511
512		assert_eq!(get_statuses(repo_path), (0, 1));
513
514		let diff = get_diff(
515			repo_path,
516			file_path.to_str().unwrap(),
517			true,
518			None,
519		)
520		.unwrap();
521
522		assert_eq!(diff.hunks.len(), 1);
523	}
524
525	static HUNK_A: &str = r"
5261   start
5272
5283
5294
5305
5316   middle
5327
5338
5349
5350
5361   end";
537
538	static HUNK_B: &str = r"
5391   start
5402   newa
5413
5424
5435
5446   middle
5457
5468
5479
5480   newb
5491   end";
550
551	#[test]
552	fn test_hunks() {
553		let (_td, repo) = repo_init().unwrap();
554		let root = repo.path().parent().unwrap();
555		let repo_path: &RepoPath =
556			&root.as_os_str().to_str().unwrap().into();
557
558		assert_eq!(get_statuses(repo_path), (0, 0));
559
560		let file_path = root.join("bar.txt");
561
562		{
563			File::create(&file_path)
564				.unwrap()
565				.write_all(HUNK_A.as_bytes())
566				.unwrap();
567		}
568
569		let res = get_status(repo_path, StatusType::WorkingDir, None)
570			.unwrap();
571		assert_eq!(res.len(), 1);
572		assert_eq!(res[0].path, "bar.txt");
573
574		stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
575		assert_eq!(get_statuses(repo_path), (0, 1));
576
577		// overwrite with next content
578		{
579			File::create(&file_path)
580				.unwrap()
581				.write_all(HUNK_B.as_bytes())
582				.unwrap();
583		}
584
585		assert_eq!(get_statuses(repo_path), (1, 1));
586
587		let res =
588			get_diff(repo_path, "bar.txt", false, None).unwrap();
589
590		assert_eq!(res.hunks.len(), 2)
591	}
592
593	#[test]
594	fn test_diff_newfile_in_sub_dir_current_dir() {
595		let file_path = Path::new("foo/foo.txt");
596		let (_td, repo) = repo_init_empty().unwrap();
597		let root = repo.path().parent().unwrap();
598
599		let sub_path = root.join("foo/");
600
601		fs::create_dir_all(&sub_path).unwrap();
602		File::create(root.join(file_path))
603			.unwrap()
604			.write_all(b"test")
605			.unwrap();
606
607		let diff = get_diff(
608			&sub_path.to_str().unwrap().into(),
609			file_path.to_str().unwrap(),
610			false,
611			None,
612		)
613		.unwrap();
614
615		assert_eq!(&*diff.hunks[0].lines[1].content, "test");
616	}
617
618	#[test]
619	fn test_diff_delta_size() -> Result<()> {
620		let file_path = Path::new("bar");
621		let (_td, repo) = repo_init_empty().unwrap();
622		let root = repo.path().parent().unwrap();
623		let repo_path: &RepoPath =
624			&root.as_os_str().to_str().unwrap().into();
625
626		File::create(root.join(file_path))?.write_all(b"\x00")?;
627
628		stage_add_file(repo_path, file_path).unwrap();
629
630		commit(repo_path, "commit").unwrap();
631
632		File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
633
634		let diff = get_diff(
635			repo_path,
636			file_path.to_str().unwrap(),
637			false,
638			None,
639		)
640		.unwrap();
641
642		dbg!(&diff);
643		assert_eq!(diff.sizes, (1, 2));
644		assert_eq!(diff.size_delta, 1);
645
646		Ok(())
647	}
648
649	#[test]
650	fn test_binary_diff_delta_size_untracked() -> Result<()> {
651		let file_path = Path::new("bar");
652		let (_td, repo) = repo_init_empty().unwrap();
653		let root = repo.path().parent().unwrap();
654		let repo_path: &RepoPath =
655			&root.as_os_str().to_str().unwrap().into();
656
657		File::create(root.join(file_path))?.write_all(b"\x00\xc7")?;
658
659		let diff = get_diff(
660			repo_path,
661			file_path.to_str().unwrap(),
662			false,
663			None,
664		)
665		.unwrap();
666
667		dbg!(&diff);
668		assert_eq!(diff.sizes, (0, 2));
669		assert_eq!(diff.size_delta, 2);
670
671		Ok(())
672	}
673
674	#[test]
675	fn test_diff_delta_size_commit() -> Result<()> {
676		let file_path = Path::new("bar");
677		let (_td, repo) = repo_init_empty().unwrap();
678		let root = repo.path().parent().unwrap();
679		let repo_path: &RepoPath =
680			&root.as_os_str().to_str().unwrap().into();
681
682		File::create(root.join(file_path))?.write_all(b"\x00")?;
683
684		stage_add_file(repo_path, file_path).unwrap();
685
686		commit(repo_path, "").unwrap();
687
688		File::create(root.join(file_path))?.write_all(b"\x00\x02")?;
689
690		stage_add_file(repo_path, file_path).unwrap();
691
692		let id = commit(repo_path, "").unwrap();
693
694		let diff =
695			get_diff_commit(repo_path, id, String::new(), None)
696				.unwrap();
697
698		dbg!(&diff);
699		assert_eq!(diff.sizes, (1, 2));
700		assert_eq!(diff.size_delta, 1);
701
702		Ok(())
703	}
704}