asyncgnit 0.0.1

allow using git2 in a asynchronous context
Documentation
//! Sync git API for fetching a file blame

use std::{
	collections::{HashMap, HashSet},
	io::{BufRead, BufReader},
	path::Path,
};

use git2::BlameOptions;
use scopetime::scope_time;

use super::{utils, CommitId, RepoPath};
use crate::{
	error::{Error, Result},
	sync::{get_commits_info, repository::repo},
};

/// A `BlameHunk` contains all the information that will be shown to
/// the user.
#[derive(Clone, Hash, Debug, PartialEq, Eq)]
pub struct BlameHunk {
	///
	pub commit_id: CommitId,
	///
	pub author: String,
	///
	pub time: i64,
	/// `git2::BlameHunk::final_start_line` returns 1-based indices,
	/// but `start_line` is 0-based because the `Vec` storing the
	/// lines starts at index 0.
	pub start_line: usize,
	///
	pub end_line: usize,
}

/// A `BlameFile` represents a collection of lines. This is targeted
/// at how the data will be used by the UI.
#[derive(Clone, Debug)]
pub struct FileBlame {
	///
	pub commit_id: CommitId,
	///
	pub path: String,
	///
	pub lines: Vec<(Option<BlameHunk>, String)>,
}

/// fixup `\` windows path separators to git compatible `/`
fn fixup_windows_path(path: &str) -> String {
	#[cfg(windows)]
	{
		path.replace('\\', "/")
	}

	#[cfg(not(windows))]
	{
		path.to_string()
	}
}

///
pub fn blame_file(
	repo_path: &RepoPath,
	file_path: &str,
	commit_id: Option<CommitId>,
) -> Result<FileBlame> {
	scope_time!("blame_file");

	let repo = repo(repo_path)?;

	let commit_id = if let Some(commit_id) = commit_id {
		commit_id
	} else {
		utils::get_head_repo(&repo)?
	};

	let spec =
		format!("{}:{}", commit_id, fixup_windows_path(file_path));

	let object = repo.revparse_single(&spec)?;
	let blob = repo.find_blob(object.id())?;

	if blob.is_binary() {
		return Err(Error::NoBlameOnBinaryFile);
	}

	let mut opts = BlameOptions::new();
	opts.newest_commit(commit_id.into());

	let blame =
		repo.blame_file(Path::new(file_path), Some(&mut opts))?;

	let reader = BufReader::new(blob.content());

	let unique_commit_ids: HashSet<_> = blame
		.iter()
		.map(|hunk| CommitId::new(hunk.final_commit_id()))
		.collect();
	let mut commit_ids = Vec::with_capacity(unique_commit_ids.len());
	commit_ids.extend(unique_commit_ids);

	let commit_infos = get_commits_info(repo_path, &commit_ids, 0)?;
	let unique_commit_infos: HashMap<_, _> = commit_infos
		.iter()
		.map(|commit_info| (commit_info.id, commit_info))
		.collect();

	let lines: Vec<(Option<BlameHunk>, String)> = reader
		.lines()
		.enumerate()
		.map(|(i, line)| {
			// Line indices in a `FileBlame` are 1-based.
			let corresponding_hunk = blame.get_line(i + 1);

			if let Some(hunk) = corresponding_hunk {
				let commit_id = CommitId::new(hunk.final_commit_id());
				// Line indices in a `BlameHunk` are 1-based.
				let start_line =
					hunk.final_start_line().saturating_sub(1);
				let end_line =
					start_line.saturating_add(hunk.lines_in_hunk());

				if let Some(commit_info) =
					unique_commit_infos.get(&commit_id)
				{
					let hunk = BlameHunk {
						commit_id,
						author: commit_info.author.clone(),
						time: commit_info.time,
						start_line,
						end_line,
					};

					return (
						Some(hunk),
						line.unwrap_or_else(|_| String::new()),
					);
				}
			}

			(None, line.unwrap_or_else(|_| String::new()))
		})
		.collect();

	let file_blame = FileBlame {
		commit_id,
		path: file_path.into(),
		lines,
	};

	Ok(file_blame)
}

#[cfg(test)]
mod tests {
	use std::{
		fs::{File, OpenOptions},
		io::Write,
		path::Path,
	};

	use super::*;
	use crate::{
		error::Result,
		sync::{commit, stage_add_file, tests::repo_init_empty},
	};

	#[test]
	fn test_blame() -> Result<()> {
		let file_path = Path::new("foo");
		let (_td, repo) = repo_init_empty()?;
		let root = repo.path().parent().unwrap();
		let repo_path: &RepoPath =
			&root.as_os_str().to_str().unwrap().into();

		assert!(matches!(blame_file(repo_path, "foo", None), Err(_)));

		File::create(root.join(file_path))?.write_all(b"line 1\n")?;

		stage_add_file(repo_path, file_path)?;
		commit(repo_path, "first commit")?;

		let blame = blame_file(repo_path, "foo", None)?;

		assert!(matches!(
			blame.lines.as_slice(),
			[(
				Some(BlameHunk {
					author,
					start_line: 0,
					end_line: 1,
					..
				}),
				line
			)] if author == "name" && line == "line 1"
		));

		let mut file = OpenOptions::new()
			.append(true)
			.open(root.join(file_path))?;

		file.write(b"line 2\n")?;

		stage_add_file(repo_path, file_path)?;
		commit(repo_path, "second commit")?;

		let blame = blame_file(repo_path, "foo", None)?;

		assert!(matches!(
			blame.lines.as_slice(),
			[
				(
					Some(BlameHunk {
						start_line: 0,
						end_line: 1,
						..
					}),
					first_line
				),
				(
					Some(BlameHunk {
						author,
						start_line: 1,
						end_line: 2,
						..
					}),
					second_line
				)
			] if author == "name" && first_line == "line 1" && second_line == "line 2"
		));

		file.write(b"line 3\n")?;

		let blame = blame_file(repo_path, "foo", None)?;

		assert_eq!(blame.lines.len(), 2);

		stage_add_file(repo_path, file_path)?;
		commit(repo_path, "third commit")?;

		let blame = blame_file(repo_path, "foo", None)?;

		assert_eq!(blame.lines.len(), 3);

		Ok(())
	}

	#[test]
	fn test_blame_windows_path_dividers() {
		let file_path = Path::new("bar\\foo");
		let (_td, repo) = repo_init_empty().unwrap();
		let root = repo.path().parent().unwrap();
		let repo_path: &RepoPath =
			&root.as_os_str().to_str().unwrap().into();

		std::fs::create_dir(root.join("bar")).unwrap();

		File::create(root.join(file_path))
			.unwrap()
			.write_all(b"line 1\n")
			.unwrap();

		stage_add_file(repo_path, file_path).unwrap();
		commit(repo_path, "first commit").unwrap();

		assert!(blame_file(repo_path, "bar\\foo", None).is_ok());
	}
}